From b9d5b185219ced78945019397cc9320303c7140f Mon Sep 17 00:00:00 2001 From: Kerem Noras Date: Sat, 13 Sep 2025 11:59:25 +0300 Subject: [PATCH 1/4] feat: Major improvements to session management and UI Session Management Enhancements: - Add session name support to all commands (attach, kill, info, rename, history) - Implement partial name matching for session identification - Fix Ctrl+D to only detach current client, not terminate session - Fix detach process hanging after showing detach message - Add terminal resize support for multi-client sessions - Implement session switching within attached sessions (~s command) UI/UX Improvements: - Complete rewrite of interactive picker using ratatui for better TUI - Add current session highlighting in both list and interactive views - Implement sleek, modern UI design with minimal borders - Show comprehensive session info (PID, uptime, created time, working dir, client count) - Add special time format for sessions older than 24h (e.g., "2d, 14:30") - Fix cursor shape preservation after client detachment Technical Improvements: - Add ClientInfo struct for per-client terminal tracking - Implement environment variables (NDS_SESSION_ID, NDS_SESSION_NAME) for session identification - Add process tree walking for current session detection - Fix terminal mode switching for user input during session switching - Implement non-blocking I/O for proper input handling - Add tcflush() for input buffer clearing during detach Documentation: - Update README with new environment variables - Document session name support in all relevant commands - Add keyboard shortcuts documentation - Update examples with name-based operations --- Cargo.lock | 229 ++++++++++++++++++++++- Cargo.toml | 3 +- README.md | 27 ++- src/interactive.rs | 454 +++++++++++++++++++++++++++++++++++---------- src/main.rs | 217 ++++++++++++++++------ src/manager.rs | 67 +++++-- src/pty.rs | 201 +++++++++++++++++--- 7 files changed, 996 insertions(+), 202 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8050d7e..a79d11f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -121,6 +127,21 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.35" @@ -204,6 +225,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -245,9 +280,44 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "detached-shell" -version = "0.1.0" +version = "0.1.1" dependencies = [ "assert_cmd", "chrono", @@ -258,6 +328,7 @@ dependencies = [ "libc", "nix 0.29.0", "predicates", + "ratatui", "serde", "serde_json", "tempfile", @@ -298,6 +369,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.13" @@ -329,6 +412,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "getrandom" version = "0.2.16" @@ -352,6 +447,17 @@ dependencies = [ "wasi 0.14.3+wasi-0.2.4", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -382,12 +488,46 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -448,6 +588,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.7.5" @@ -546,6 +695,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "predicates" version = "3.1.3" @@ -600,6 +755,27 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -767,12 +943,40 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.106" @@ -829,6 +1033,29 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 3b02300..c50e4b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "detached-shell" -version = "0.1.0" +version = "0.1.1" readme = "README.md" edition = "2021" authors = ["Noras "] @@ -23,6 +23,7 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5", features = ["derive"] } crossterm = "0.28" +ratatui = "0.28" nix = { version = "0.29", features = ["process", "signal", "term", "fs"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/README.md b/README.md index 331fb17..35f0a4a 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ Simple detachable shell sessions with zero configuration. Not a complex multiple - ๐ŸŽฏ **Simple Session Management**: Create, detach, and reattach shell sessions with ease - ๐Ÿชถ **Lightweight**: < 1MB single binary with zero configuration required - โšก **Fast**: Written in Rust for maximum performance -- ๐ŸŽจ **User-Friendly**: Intuitive commands with partial ID matching -- ๐Ÿ–ฅ๏ธ **Interactive Mode**: Visual session picker with arrow key navigation +- ๐ŸŽจ **User-Friendly**: Intuitive commands with partial ID and name matching +- ๐Ÿ–ฅ๏ธ **Interactive Mode**: Sleek TUI session picker with real-time status - ๐Ÿ“ **Session History**: Track all session events with persistent history - ๐Ÿงน **Auto-Cleanup**: Automatic cleanup of dead sessions - ๐Ÿ”„ **Session Switching**: Simple attach/detach without complex multiplexing @@ -92,15 +92,18 @@ nds new --no-attach nds list nds ls -# Interactive session picker -nds # or nds list -i +# Interactive session picker with TUI +nds interactive # or just 'nds' for short -# Attach to a session (supports partial ID matching) +# Attach to a session (supports partial ID and name matching) nds attach abc123 +nds attach project-dev # attach by name nds a abc # partial ID works +nds a proj # partial name works -# Kill sessions +# Kill sessions (supports ID and name) nds kill abc123 +nds kill project-dev # kill by name nds kill abc def ghi # kill multiple sessions # Clean up dead sessions @@ -110,11 +113,13 @@ nds clean ### Session Information ```bash -# Get detailed info about a session +# Get detailed info about a session (supports ID and name) nds info abc123 +nds info project-dev # info by name -# Rename a session +# Rename a session (supports ID and name) nds rename abc123 "new-name" +nds rename project-dev "production" # rename by current name # View session history nds history # Active sessions only @@ -125,6 +130,8 @@ nds history -s abc123 # History for specific session ### Keyboard Shortcuts (Inside Session) - `Enter, ~d` - Detach from current session (like SSH's `~.` sequence) +- `Ctrl+D` - Detach from current session (when at empty prompt) +- `Enter, ~s` - Switch to another session interactively ## ๐Ÿ—๏ธ Architecture @@ -157,6 +164,10 @@ NDS works out of the box with zero configuration. However, you can customize: # Change default shell (default: $SHELL or /bin/sh) export NDS_SHELL=/bin/zsh +# Session identification (automatically set inside sessions) +NDS_SESSION_ID # Current session ID when attached +NDS_SESSION_NAME # Current session name (if set) + # Change detach key binding (coming soon) export NDS_DETACH_KEY="ctrl-a d" ``` diff --git a/src/interactive.rs b/src/interactive.rs index 7f9633d..c825d80 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -1,17 +1,27 @@ use crate::{NdsError, Result, Session, SessionManager}; +use chrono::Timelike; use crossterm::{ - cursor::{Hide, MoveTo, Show}, - event::{self, Event, KeyCode, KeyEvent}, + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, - style::{Color, Print, ResetColor, SetForegroundColor, Stylize}, - terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{self, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, Terminal, +}; +use std::{ + io, + time::{Duration, Instant}, }; -use std::io::{self, Write}; -use std::time::Duration; pub struct InteractivePicker { sessions: Vec, - selected: usize, + state: ListState, + current_session_id: Option, } impl InteractivePicker { @@ -21,133 +31,381 @@ impl InteractivePicker { return Err(NdsError::SessionNotFound("No active sessions".to_string())); } - Ok(Self { - sessions, - selected: 0, - }) + let mut state = ListState::default(); + state.select(Some(0)); + + // Check if we're currently attached to a session + let mut current_session_id = std::env::var("NDS_SESSION_ID").ok(); + + // Fallback: If no environment variable, try to detect from parent processes + if current_session_id.is_none() { + current_session_id = Self::detect_current_session(&sessions); + } + + Ok(Self { sessions, state, current_session_id }) } + fn detect_current_session(sessions: &[Session]) -> Option { + // Try to detect current session by checking parent processes + let mut ppid = std::process::id(); + + // Walk up the process tree (max 10 levels to avoid infinite loops) + for _ in 0..10 { + // Get parent process ID + let ppid_result = Self::get_parent_pid(ppid as i32); + if let Some(parent_pid) = ppid_result { + // Check if this PID matches any session + for session in sessions { + if session.pid == parent_pid { + return Some(session.id.clone()); + } + } + ppid = parent_pid as u32; + } else { + break; + } + } + + None + } + + fn get_parent_pid(pid: i32) -> Option { + // Read /proc/[pid]/stat on Linux or use ps on macOS + #[cfg(target_os = "macos")] + { + use std::process::Command; + let output = Command::new("ps") + .args(&["-p", &pid.to_string(), "-o", "ppid="]) + .output() + .ok()?; + + if output.status.success() { + let ppid_str = String::from_utf8_lossy(&output.stdout); + ppid_str.trim().parse::().ok() + } else { + None + } + } + + #[cfg(target_os = "linux")] + { + use std::fs; + let stat_path = format!("/proc/{}/stat", pid); + let stat_content = fs::read_to_string(stat_path).ok()?; + let parts: Vec<&str> = stat_content.split_whitespace().collect(); + // Parent PID is the 4th field in /proc/[pid]/stat + if parts.len() > 3 { + parts[3].parse::().ok() + } else { + None + } + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + None + } + } + pub fn run(&mut self) -> Result> { - // Enter alternate screen + // Setup terminal + enable_raw_mode()?; let mut stdout = io::stdout(); - terminal::enable_raw_mode()?; - execute!(stdout, EnterAlternateScreen, Hide)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; - let result = self.event_loop(); + let result = self.run_app(&mut terminal); - // Clean up - execute!(stdout, LeaveAlternateScreen, Show)?; - terminal::disable_raw_mode()?; + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; result } - fn event_loop(&mut self) -> Result> { - let mut stdout = io::stdout(); + fn run_app(&mut self, terminal: &mut Terminal) -> Result> { + let mut last_tick = Instant::now(); + let tick_rate = Duration::from_millis(250); loop { - self.draw(&mut stdout)?; + terminal.draw(|f| self.ui(f))?; + + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); - if event::poll(Duration::from_millis(100))? { + if crossterm::event::poll(timeout)? { if let Event::Key(key) = event::read()? { - match self.handle_key(key) { - Some(session_id) => return Ok(Some(session_id)), - None if key.code == KeyCode::Char('q') || key.code == KeyCode::Esc => { - return Ok(None); + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(None), + KeyCode::Down | KeyCode::Char('j') => self.next(), + KeyCode::Up | KeyCode::Char('k') => self.previous(), + KeyCode::Enter => { + if let Some(selected) = self.state.selected() { + return Ok(Some(self.sessions[selected].id.clone())); + } + } + _ => {} } - _ => {} } } } + + if last_tick.elapsed() >= tick_rate { + last_tick = Instant::now(); + } } } - fn handle_key(&mut self, key: KeyEvent) -> Option { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - if self.selected > 0 { - self.selected -= 1; + fn next(&mut self) { + let i = match self.state.selected() { + Some(i) => { + if i >= self.sessions.len() - 1 { + 0 + } else { + i + 1 } } - KeyCode::Down | KeyCode::Char('j') => { - if self.selected < self.sessions.len() - 1 { - self.selected += 1; + None => 0, + }; + self.state.select(Some(i)); + } + + fn previous(&mut self) { + let i = match self.state.selected() { + Some(i) => { + if i == 0 { + self.sessions.len() - 1 + } else { + i - 1 } } - KeyCode::Enter => { - return Some(self.sessions[self.selected].id.clone()); - } - _ => {} - } - None + None => 0, + }; + self.state.select(Some(i)); } - fn draw(&self, stdout: &mut io::Stdout) -> Result<()> { - execute!(stdout, Clear(ClearType::All), MoveTo(0, 0))?; + fn ui(&mut self, f: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(f.area()); - // Header - execute!( - stdout, - SetForegroundColor(Color::Cyan), - Print("NDS - Interactive Session Picker\n"), - ResetColor, - Print("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n\n") - )?; - - // Instructions - execute!( - stdout, - SetForegroundColor(Color::DarkGrey), - Print("โ†‘/k: up โ†“/j: down Enter: attach q/Esc: quit\n\n"), - ResetColor - )?; + // Header - more minimal + let header = Paragraph::new("SESSIONS") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Left) + .block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::DarkGray)), + ); + f.render_widget(header, chunks[0]); // Sessions list - for (i, session) in self.sessions.iter().enumerate() { - if i == self.selected { - execute!(stdout, SetForegroundColor(Color::Green), Print("โ–ถ "))?; - } else { - execute!(stdout, Print(" "))?; - } + let items: Vec = self + .sessions + .iter() + .map(|session| { + let client_count = session.get_client_count(); - // Session info - let now = chrono::Utc::now().timestamp(); - let created = session.created_at.timestamp(); - let duration = now - created; - let uptime = format_duration(duration as u64); - - let line = format!( - "{:<20} PID: {:<8} Uptime: {:<12} {}", - session.display_name(), - session.pid, - uptime, - if session.attached { - "[ATTACHED]".green().to_string() + let now = chrono::Utc::now().timestamp(); + let created = session.created_at.timestamp(); + let duration = now - created; + let uptime = format_duration(duration as u64); + + // Check if this is the current attached session + let is_current = self.current_session_id.as_ref() == Some(&session.id); + + // Status indicator - simplified + let (status_icon, status_color) = if is_current { + ("โ˜…", Color::Cyan) + } else if client_count > 0 { + ("โ—", Color::Green) + } else { + ("โ—‹", Color::Gray) + }; + + // Session name styling + let name_style = if is_current { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + // Build the status text that appears on the right + let status_text = if is_current { + if client_count > 0 { + format!("CURRENT SESSION ยท {} CLIENT{}", client_count, if client_count == 1 { "" } else { "S" }) + } else { + "CURRENT SESSION".to_string() + } + } else if client_count > 0 { + format!("{} CLIENT{}", client_count, if client_count == 1 { "" } else { "S" }) } else { - "".to_string() + "DETACHED".to_string() + }; + + // Format created time + let now = chrono::Local::now(); + let local_time: chrono::DateTime = session.created_at.into(); + let duration = now.signed_duration_since(local_time); + + let created_time = if duration.num_days() > 0 { + format!("{}d, {:02}:{:02}", + duration.num_days(), + local_time.hour(), + local_time.minute()) + } else { + local_time.format("%H:%M:%S").to_string() + }; + + // Truncate working dir if too long + let mut working_dir = session.working_dir.clone(); + if working_dir.len() > 30 { + working_dir = format!("...{}", &session.working_dir[session.working_dir.len() - 27..]); } - ); + + // Build left side with fixed widths + let left_side = format!( + " {} {:<25} โ”‚ PID {:<6} โ”‚ {:<8} โ”‚ {:<8} โ”‚ {:<30}", + status_icon, + session.display_name(), + session.pid, + uptime, + created_time, + working_dir + ); + + // Calculate padding for right alignment + let terminal_width = terminal::size().unwrap_or((80, 24)).0 as usize; + let left_len = left_side.chars().count(); + let status_len = status_text.chars().count(); + let padding = terminal_width.saturating_sub(left_len + status_len + 2); - if i == self.selected { - execute!(stdout, Print(line.bold()), ResetColor)?; - } else { - execute!(stdout, Print(line))?; - } + let content = vec![ + Line::from(vec![ + Span::styled( + format!(" {} ", status_icon), + Style::default().fg(status_color).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{:<25}", session.display_name()), + name_style, + ), + Span::styled( + " โ”‚ ", + Style::default().fg(Color::DarkGray), + ), + Span::styled( + format!("PID {:<6}", session.pid), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + " โ”‚ ", + Style::default().fg(Color::DarkGray), + ), + Span::styled( + format!("{:<8}", uptime), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + " โ”‚ ", + Style::default().fg(Color::DarkGray), + ), + Span::styled( + format!("{:<8}", created_time), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + " โ”‚ ", + Style::default().fg(Color::DarkGray), + ), + Span::styled( + format!("{:<30}", working_dir), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + " ".repeat(padding), + Style::default(), + ), + Span::styled( + status_text.clone(), + if is_current { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else if client_count > 0 { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM) + }, + ), + ]), + ]; + ListItem::new(content) + }) + .collect(); - execute!(stdout, Print("\n"))?; - } + let sessions_list = List::new(items) + .block( + Block::default() + .borders(Borders::NONE), + ) + .highlight_style( + Style::default() + .bg(Color::Rgb(40, 40, 40)) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(""); - // Footer - execute!( - stdout, - Print("\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n"), - SetForegroundColor(Color::DarkGrey), - Print(format!("{} active session(s)\n", self.sessions.len())), - ResetColor - )?; + f.render_stateful_widget(sessions_list, chunks[1], &mut self.state); - stdout.flush()?; - Ok(()) + // Footer - cleaner design + let help_text = vec![ + Span::styled("โ†‘โ†“/jk ", Style::default().fg(Color::DarkGray)), + Span::styled("navigate", Style::default().fg(Color::Gray)), + Span::styled(" ", Style::default()), + Span::styled("โŽ ", Style::default().fg(Color::DarkGray)), + Span::styled("attach", Style::default().fg(Color::Gray)), + Span::styled(" ", Style::default()), + Span::styled("q ", Style::default().fg(Color::DarkGray)), + Span::styled("quit", Style::default().fg(Color::Gray)), + ]; + + let session_info = format!("{} sessions", self.sessions.len()); + + let footer = Paragraph::new(Line::from(help_text)) + .style(Style::default()) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray)), + ); + f.render_widget(footer, chunks[2]); + + // Session count on the right + let count_widget = Paragraph::new(session_info) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Right); + let count_area = Rect { + x: chunks[2].x + 2, + y: chunks[2].y + 1, + width: chunks[2].width - 4, + height: 1, + }; + f.render_widget(count_widget, count_area); } } @@ -161,4 +419,4 @@ fn format_duration(seconds: u64) -> String { } else { format!("{}d {}h", seconds / 86400, (seconds % 86400) / 3600) } -} +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index d3cacfe..64a9efc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,28 +34,28 @@ enum Commands { /// Attach to an existing session #[command(aliases = &["a", "at"])] Attach { - /// Session ID to attach to (first 8 characters of UUID) + /// Session ID or name to attach to (supports partial matching) id: String, }, /// Kill one or more sessions #[command(aliases = &["k"])] Kill { - /// Session IDs to kill + /// Session IDs or names to kill (supports partial matching) ids: Vec, }, /// Show information about a specific session #[command(aliases = &["i"])] Info { - /// Session ID to get info about + /// Session ID or name to get info about (supports partial matching) id: String, }, /// Rename a session #[command(aliases = &["rn"])] Rename { - /// Session ID to rename + /// Session ID or name to rename (supports partial matching) id: String, /// New name for the session new_name: String, @@ -67,7 +67,7 @@ enum Commands { /// Show session history #[command(aliases = &["h", "hist"])] History { - /// Show history for a specific session ID + /// Show history for a specific session ID or name (supports partial matching) #[arg(short, long)] session: Option, @@ -188,18 +188,36 @@ fn handle_list_sessions(interactive: bool) -> Result<()> { Ok(()) } -fn handle_attach_session(session_id: &str) -> Result<()> { - // Allow partial ID matching +fn handle_attach_session(session_id_or_name: &str) -> Result<()> { + // Allow partial ID or name matching let sessions = SessionManager::list_sessions()?; - let matching_sessions: Vec<_> = sessions + + // First try to match by ID + let mut matching_sessions: Vec<_> = sessions .iter() - .filter(|s| s.id.starts_with(session_id)) + .filter(|s| s.id.starts_with(session_id_or_name)) .collect(); + + // If no ID matches, try matching by name + if matching_sessions.is_empty() { + matching_sessions = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name == session_id_or_name || + name.starts_with(session_id_or_name) || + name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + } match matching_sessions.len() { 0 => { - eprintln!("No session found matching ID: {}", session_id); - Err(NdsError::SessionNotFound(session_id.to_string())) + eprintln!("No session found matching ID or name: {}", session_id_or_name); + Err(NdsError::SessionNotFound(session_id_or_name.to_string())) } 1 => { let session = matching_sessions[0]; @@ -208,13 +226,13 @@ fn handle_attach_session(session_id: &str) -> Result<()> { } _ => { eprintln!( - "Multiple sessions match ID '{}'. Please be more specific:", - session_id + "Multiple sessions match '{}'. Please be more specific:", + session_id_or_name ); for session in matching_sessions { - eprintln!(" - {}", session.id); + eprintln!(" - {}", session.display_name()); } - Err(NdsError::InvalidSessionId(session_id.to_string())) + Err(NdsError::InvalidSessionId(session_id_or_name.to_string())) } } } @@ -255,17 +273,33 @@ fn handle_kill_sessions(session_ids: &[String]) -> Result<()> { } } -fn kill_single_session(session_id: &str, sessions: &[Session]) -> Result { - // Allow partial ID matching - let matching_sessions: Vec<_> = sessions +fn kill_single_session(session_id_or_name: &str, sessions: &[Session]) -> Result { + // Allow partial ID or name matching + let mut matching_sessions: Vec<_> = sessions .iter() - .filter(|s| s.id.starts_with(session_id)) + .filter(|s| s.id.starts_with(session_id_or_name)) .collect(); + + // If no ID matches, try matching by name + if matching_sessions.is_empty() { + matching_sessions = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name == session_id_or_name || + name.starts_with(session_id_or_name) || + name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + } match matching_sessions.len() { 0 => Err(NdsError::SessionNotFound(format!( - "No session found matching ID: {}", - session_id + "No session found matching ID or name: {}", + session_id_or_name ))), 1 => { let session = matching_sessions[0]; @@ -273,32 +307,58 @@ fn kill_single_session(session_id: &str, sessions: &[Session]) -> Result Ok(session.id.clone()) } _ => { - let matches: Vec = matching_sessions.iter().map(|s| s.id.clone()).collect(); + let matches: Vec = matching_sessions + .iter() + .map(|s| s.display_name()) + .collect(); Err(NdsError::SessionNotFound(format!( "Multiple sessions match '{}': {}. Please be more specific", - session_id, + session_id_or_name, matches.join(", ") ))) } } } -fn handle_session_info(session_id: &str) -> Result<()> { - // Allow partial ID matching +fn handle_session_info(session_id_or_name: &str) -> Result<()> { + // Allow partial ID or name matching let sessions = SessionManager::list_sessions()?; - let matching_sessions: Vec<_> = sessions + + // First try to match by ID + let mut matching_sessions: Vec<_> = sessions .iter() - .filter(|s| s.id.starts_with(session_id)) + .filter(|s| s.id.starts_with(session_id_or_name)) .collect(); + + // If no ID matches, try matching by name + if matching_sessions.is_empty() { + matching_sessions = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name == session_id_or_name || + name.starts_with(session_id_or_name) || + name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + } match matching_sessions.len() { 0 => { - eprintln!("No session found matching ID: {}", session_id); - Err(NdsError::SessionNotFound(session_id.to_string())) + eprintln!("No session found matching ID or name: {}", session_id_or_name); + Err(NdsError::SessionNotFound(session_id_or_name.to_string())) } 1 => { let session = matching_sessions[0]; + let client_count = session.get_client_count(); + println!("Session ID: {}", session.id); + if let Some(ref name) = session.name { + println!("Session Name: {}", name); + } println!("PID: {}", session.pid); println!("Created: {}", session.created_at); println!("Socket: {}", session.socket_path.display()); @@ -306,55 +366,74 @@ fn handle_session_info(session_id: &str) -> Result<()> { println!("Working Directory: {}", session.working_dir); println!( "Status: {}", - if session.attached { - "Attached" + if client_count > 0 { + format!("Attached ({} client(s))", client_count) } else { - "Detached" + "Detached".to_string() } ); Ok(()) } _ => { eprintln!( - "Multiple sessions match ID '{}'. Please be more specific:", - session_id + "Multiple sessions match '{}'. Please be more specific:", + session_id_or_name ); for session in matching_sessions { - eprintln!(" - {}", session.id); + eprintln!(" - {}", session.display_name()); } - Err(NdsError::InvalidSessionId(session_id.to_string())) + Err(NdsError::InvalidSessionId(session_id_or_name.to_string())) } } } -fn handle_rename_session(session_id: &str, new_name: &str) -> Result<()> { - // Allow partial ID matching +fn handle_rename_session(session_id_or_name: &str, new_name: &str) -> Result<()> { + // Allow partial ID or name matching let sessions = SessionManager::list_sessions()?; - let matching_sessions: Vec<_> = sessions + + // First try to match by ID + let mut matching_sessions: Vec<_> = sessions .iter() - .filter(|s| s.id.starts_with(session_id)) + .filter(|s| s.id.starts_with(session_id_or_name)) .collect(); + + // If no ID matches, try matching by name + if matching_sessions.is_empty() { + matching_sessions = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name == session_id_or_name || + name.starts_with(session_id_or_name) || + name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + } match matching_sessions.len() { 0 => { - eprintln!("No session found matching ID: {}", session_id); - Err(NdsError::SessionNotFound(session_id.to_string())) + eprintln!("No session found matching ID or name: {}", session_id_or_name); + Err(NdsError::SessionNotFound(session_id_or_name.to_string())) } 1 => { let session = matching_sessions[0]; + let old_display_name = session.display_name(); SessionManager::rename_session(&session.id, new_name)?; - println!("Renamed session {} to '{}'", session.id, new_name); + println!("Renamed session {} to '{}'", old_display_name, new_name); Ok(()) } _ => { eprintln!( - "Multiple sessions match ID '{}'. Please be more specific:", - session_id + "Multiple sessions match '{}'. Please be more specific:", + session_id_or_name ); for session in matching_sessions { - eprintln!(" - {}", session.id); + eprintln!(" - {}", session.display_name()); } - Err(NdsError::InvalidSessionId(session_id.to_string())) + Err(NdsError::InvalidSessionId(session_id_or_name.to_string())) } } } @@ -366,22 +445,56 @@ fn handle_clean_sessions() -> Result<()> { Ok(()) } -fn handle_session_history(session_id: Option, all: bool, limit: usize) -> Result<()> { +fn handle_session_history(session_id_or_name: Option, all: bool, limit: usize) -> Result<()> { use chrono::{DateTime, Local}; // Migrate old format if needed let _ = SessionHistory::migrate_from_single_file(); - if let Some(ref id) = session_id { + if let Some(ref id_or_name) = session_id_or_name { + // First try to resolve session name to ID + let sessions = SessionManager::list_sessions()?; + let resolved_id = if sessions.iter().any(|s| s.id == *id_or_name) { + // It's already a session ID + id_or_name.clone() + } else { + // Try to find by name (case-insensitive partial matching) + let matches: Vec<&Session> = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name.to_lowercase().contains(&id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + + match matches.len() { + 0 => { + println!("No session found with ID or name matching: {}", id_or_name); + return Ok(()); + } + 1 => matches[0].id.clone(), + _ => { + println!("Multiple sessions match '{}'. Please be more specific:", id_or_name); + for session in matches { + println!(" {} [{}]", session.display_name(), session.id); + } + return Ok(()); + } + } + }; + // Show history for specific session - let entries = SessionHistory::get_session_history(id)?; + let entries = SessionHistory::get_session_history(&resolved_id)?; if entries.is_empty() { - println!("No history found for session: {}", id); + println!("No history found for session: {}", resolved_id); return Ok(()); } - println!("History for session {}:", id); + println!("History for session {}:", resolved_id); println!("{:-<80}", ""); for entry in entries.iter().take(limit) { diff --git a/src/manager.rs b/src/manager.rs index f0936bc..1722d52 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Local, Utc}; +use chrono::{DateTime, Local, Timelike, Utc}; use std::fmt; use crate::error::Result; @@ -127,11 +127,16 @@ impl SessionManager { // Helper for pretty-printing sessions pub struct SessionDisplay<'a> { pub session: &'a Session, + pub is_current: bool, } impl<'a> SessionDisplay<'a> { pub fn new(session: &'a Session) -> Self { - SessionDisplay { session } + SessionDisplay { session, is_current: false } + } + + pub fn with_current(session: &'a Session, is_current: bool) -> Self { + SessionDisplay { session, is_current } } fn format_duration(&self) -> String { @@ -150,8 +155,18 @@ impl<'a> SessionDisplay<'a> { } fn format_time(&self) -> String { + let now = Local::now(); let local_time: DateTime = self.session.created_at.into(); - local_time.format("%H:%M:%S").to_string() + let duration = now.signed_duration_since(local_time); + + if duration.num_days() > 0 { + format!("{}d, {:02}:{:02}", + duration.num_days(), + local_time.hour(), + local_time.minute()) + } else { + local_time.format("%H:%M:%S").to_string() + } } } @@ -159,32 +174,48 @@ impl<'a> fmt::Display for SessionDisplay<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Get client count let client_count = self.session.get_client_count(); - let status = if client_count > 0 { - format!("attached({})", client_count) + + // Status icon and color + let (icon, status_text) = if self.is_current { + ("โ˜…", format!("CURRENT ยท {} client{}", client_count, if client_count == 1 { "" } else { "s" })) + } else if client_count > 0 { + ("โ—", format!("{} client{}", client_count, if client_count == 1 { "" } else { "s" })) } else { - "detached".to_string() + ("โ—‹", "detached".to_string()) }; + + // Truncate working dir if too long + let mut working_dir = self.session.working_dir.clone(); + if working_dir.len() > 30 { + // Show last 27 chars with ellipsis + working_dir = format!("...{}", &self.session.working_dir[self.session.working_dir.len() - 27..]); + } + // Format with sleek layout including all info write!( f, - "{:<20} {:<10} {:<12} {:<10} {:<20} {}", + " {} {:<25} โ”‚ PID {:<6} โ”‚ {:<8} โ”‚ {:<8} โ”‚ {:<30} โ”‚ {}", + icon, self.session.display_name(), self.session.pid, - status, self.format_duration(), self.format_time(), - self.session.working_dir + working_dir, + status_text ) } } pub struct SessionTable { sessions: Vec, + current_session_id: Option, } impl SessionTable { pub fn new(sessions: Vec) -> Self { - SessionTable { sessions } + // Check if we're currently attached to a session + let current_session_id = std::env::var("NDS_SESSION_ID").ok(); + SessionTable { sessions, current_session_id } } pub fn print(&self) { @@ -193,18 +224,16 @@ impl SessionTable { return; } - // Print header - println!( - "{:<20} {:<10} {:<12} {:<10} {:<20} {}", - "SESSION", "PID", "STATUS", "UPTIME", "CREATED", "WORKING DIR" - ); - println!("{}", "-".repeat(84)); + // Sleek header + println!("SESSIONS\n"); - // Print sessions + // Print sessions with cleaner format for session in &self.sessions { - println!("{}", SessionDisplay::new(session)); + let is_current = self.current_session_id.as_ref() == Some(&session.id); + println!("{}", SessionDisplay::with_current(session, is_current)); } - println!("\nTotal: {} session(s)", self.sessions.len()); + // Footer + println!("\n{} sessions", self.sessions.len()); } } diff --git a/src/pty.rs b/src/pty.rs index 9687114..f4fc403 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -10,7 +10,7 @@ use std::thread; use crossterm::terminal; use nix::fcntl::{fcntl, FcntlArg, OFlag}; use nix::sys::signal::{kill, Signal}; -use nix::sys::termios::{tcgetattr, tcsetattr, SetArg}; +use nix::sys::termios::{tcflush, tcgetattr, tcsetattr, FlushArg, SetArg}; use nix::unistd::{close, dup2, execvp, fork, setsid, ForkResult, Pid}; use crate::error::{NdsError, Result}; @@ -20,6 +20,13 @@ use crate::scrollback::ScrollbackViewer; use crate::session::Session; use crate::terminal_state::TerminalState; +// Structure to track client information +struct ClientInfo { + stream: UnixStream, + cols: u16, + rows: u16, +} + pub struct PtyProcess { pub master_fd: RawFd, pub pid: Pid, @@ -202,6 +209,14 @@ impl PtyProcess { let _ = close(slave_fd); } + // Set environment variables for session tracking + std::env::set_var("NDS_SESSION_ID", session_id); + if let Some(ref session_name) = name { + std::env::set_var("NDS_SESSION_NAME", session_name); + } else { + std::env::set_var("NDS_SESSION_NAME", session_id); + } + // Get shell let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); @@ -260,6 +275,10 @@ impl PtyProcess { } pub fn attach_to_session(session: &Session) -> Result> { + // Set environment variable to track current attached session + std::env::set_var("NDS_SESSION_ID", &session.id); + std::env::set_var("NDS_SESSION_NAME", session.name.as_ref().unwrap_or(&session.id)); + // Save current terminal state let stdin_fd = 0; let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; @@ -389,6 +408,14 @@ impl PtyProcess { }); // Read from stdin and write to socket + let stdin_fd = 0i32; + + // Make stdin non-blocking + unsafe { + let flags = libc::fcntl(stdin_fd, libc::F_GETFL); + libc::fcntl(stdin_fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + let mut stdin = io::stdin(); let mut buffer = [0u8; 1024]; @@ -404,7 +431,13 @@ impl PtyProcess { } match stdin.read(&mut buffer) { - Ok(0) => break, + Ok(0) => { + // EOF (Ctrl+D) - treat as detach, not session termination + // Do NOT forward EOF to session as it would terminate the shell + println!("\r\n[Detaching from session {}]\r", session.id); + running.store(false, Ordering::SeqCst); + break; + }, Ok(n) => { let mut should_detach = false; let mut should_switch = false; @@ -424,6 +457,12 @@ impl PtyProcess { for i in 0..n { let byte = buffer[i]; + // Check for Ctrl+D (ASCII 4) - detach this client only + if byte == 0x04 { + should_detach = true; + break; + } + match escape_state { 0 => { // Normal state @@ -476,6 +515,7 @@ impl PtyProcess { if should_detach { println!("\r\n[Detaching from session {}]\r", session.id); + running.store(false, Ordering::SeqCst); break; } @@ -511,9 +551,24 @@ impl PtyProcess { println!("\r\nSelect option (0-{}): ", new_option_num); let _ = io::stdout().flush(); + // Temporarily restore terminal to cooked mode for input + let stdin_fd = 0; + let stdin_borrowed = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; + + // Save current raw mode settings + let current_termios = tcgetattr(&stdin_borrowed)?; + + // Restore to original (cooked) mode for line input + tcsetattr(&stdin_borrowed, SetArg::TCSANOW, &original_termios)?; + // Read user selection let mut selection = String::new(); - if let Ok(_) = io::stdin().read_line(&mut selection) { + let read_result = io::stdin().read_line(&mut selection); + + // Restore raw mode + tcsetattr(&stdin_borrowed, SetArg::TCSANOW, ¤t_termios)?; + + if let Ok(_) = read_result { if let Ok(num) = selection.trim().parse::() { if num > 0 && num <= other_sessions.len() { // Switch to selected session @@ -530,8 +585,23 @@ impl PtyProcess { println!("\r\nEnter name for new session (or press Enter for no name): "); let _ = io::stdout().flush(); + // Temporarily restore terminal to cooked mode for input + let stdin_fd = 0; + let stdin_borrowed = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; + + // Save current raw mode settings + let current_termios = tcgetattr(&stdin_borrowed)?; + + // Restore to original (cooked) mode for line input + tcsetattr(&stdin_borrowed, SetArg::TCSANOW, &original_termios)?; + let mut session_name = String::new(); - if let Ok(_) = io::stdin().read_line(&mut session_name) + let read_result = io::stdin().read_line(&mut session_name); + + // Restore raw mode + tcsetattr(&stdin_borrowed, SetArg::TCSANOW, ¤t_termios)?; + + if let Ok(_) = read_result { let session_name = session_name.trim(); let name = if session_name.is_empty() { @@ -627,6 +697,14 @@ impl PtyProcess { } } } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + // No input available, check if we should exit + if !running.load(Ordering::SeqCst) { + break; + } + thread::sleep(std::time::Duration::from_millis(10)); + continue; + } Err(e) => { eprintln!("\r\nError reading stdin: {}\r", e); break; @@ -637,33 +715,55 @@ impl PtyProcess { // Stop the socket reader thread running.store(false, Ordering::SeqCst); - // Close the socket to unblock the reader thread + // Shutdown and close the socket to immediately unblock the reader thread + let _ = socket.shutdown(std::net::Shutdown::Both); drop(socket); - // Wait for the thread with a timeout - thread::sleep(std::time::Duration::from_millis(100)); + // Wait for the thread with a shorter timeout since we shutdown the socket + thread::sleep(std::time::Duration::from_millis(50)); let _ = socket_to_stdout.join(); // Restore terminal - do this BEFORE any output let stdin_fd = 0; let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - // First restore the terminal settings + // First restore stdin to blocking mode + unsafe { + let flags = libc::fcntl(stdin_fd, libc::F_GETFL); + libc::fcntl(stdin_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); + } + + // Clear any pending input from stdin buffer + tcflush(&stdin, FlushArg::TCIFLUSH) + .map_err(|e| NdsError::TerminalError(format!("Failed to flush stdin: {}", e)))?; + + // Restore the terminal settings tcsetattr(&stdin, SetArg::TCSANOW, &original_termios) .map_err(|e| NdsError::TerminalError(format!("Failed to restore terminal: {}", e)))?; // Ensure we're back in cooked mode terminal::disable_raw_mode().ok(); + // Clear any remaining input after terminal restore + tcflush(&stdin, FlushArg::TCIFLUSH) + .map_err(|e| NdsError::TerminalError(format!("Failed to flush stdin after restore: {}", e)))?; + // Add a small delay to ensure terminal is fully restored thread::sleep(std::time::Duration::from_millis(50)); + // Clear environment variables on detach + std::env::remove_var("NDS_SESSION_ID"); + std::env::remove_var("NDS_SESSION_NAME"); + // Now it's safe to print the detach message println!("\n[Detached from session {}]", session.id); // Flush stdout to ensure message is displayed let _ = io::stdout().flush(); + // Clear any pending input from stdin to prevent hanging + tcflush(&stdin, nix::sys::termios::FlushArg::TCIFLUSH).ok(); + Ok(None) } @@ -690,8 +790,8 @@ impl PtyProcess { .take() .ok_or_else(|| NdsError::PtyError("No output buffer available".to_string()))?; - // Support multiple concurrent clients - let mut active_clients: Vec = Vec::new(); + // Support multiple concurrent clients with their terminal sizes + let mut active_clients: Vec = Vec::new(); let mut buffer = [0u8; 4096]; // Get session ID from socket path @@ -714,8 +814,8 @@ impl PtyProcess { active_clients.len() + 1 ); for client in &mut active_clients { - let _ = client.write_all(notification.as_bytes()); - let _ = client.flush(); + let _ = client.stream.write_all(notification.as_bytes()); + let _ = client.stream.flush(); } // Send buffered output to new client @@ -758,7 +858,11 @@ impl PtyProcess { } // Add new client to the list - active_clients.push(stream); + active_clients.push(ClientInfo { + stream, + cols: 80, + rows: 24, + }); // Update client count in status file let _ = Session::update_client_count(&session_id, active_clients.len()); @@ -789,7 +893,7 @@ impl PtyProcess { let mut disconnected_indices = Vec::new(); for (i, client) in active_clients.iter_mut().enumerate() { - if let Err(e) = client.write_all(data) { + if let Err(e) = client.stream.write_all(data) { if e.kind() == io::ErrorKind::BrokenPipe || e.kind() == io::ErrorKind::ConnectionAborted { @@ -797,7 +901,7 @@ impl PtyProcess { disconnected_indices.push(i); } } else { - let _ = client.flush(); + let _ = client.stream.flush(); } } @@ -810,15 +914,28 @@ impl PtyProcess { // Update client count in status file let _ = Session::update_client_count(&session_id, active_clients.len()); - // Notify remaining clients + // Notify remaining clients and refresh their terminals if !active_clients.is_empty() { let notification = format!( "\r\n[A client disconnected (remaining: {})]\r\n", active_clients.len() ); + + // Terminal refresh sequences + let refresh_sequences = [ + "\x1b[?25h", // Show cursor + "\x1b[?12h", // Enable cursor blinking + "\x1b[1 q", // Blinking block cursor (default) + "\x1b[m", // Reset all attributes + "\x1b[?1000l", // Disable mouse tracking (if enabled) + "\x1b[?1002l", // Disable cell motion mouse tracking + "\x1b[?1003l", // Disable all motion mouse tracking + ].join(""); + for client in &mut active_clients { - let _ = client.write_all(notification.as_bytes()); - let _ = client.flush(); + let _ = client.stream.write_all(notification.as_bytes()); + let _ = client.stream.write_all(refresh_sequences.as_bytes()); + let _ = client.stream.flush(); } } } @@ -845,7 +962,7 @@ impl PtyProcess { for (i, client) in active_clients.iter_mut().enumerate() { let mut client_buffer = [0u8; 1024]; - match client.read(&mut client_buffer) { + match client.stream.read(&mut client_buffer) { Ok(0) => { // Client disconnected disconnected_indices.push(i); @@ -867,7 +984,12 @@ impl PtyProcess { if let (Ok(cols), Ok(rows)) = (parts[0].parse::(), parts[1].parse::()) { - // Resize the PTY + // Update this client's terminal size + client.cols = cols; + client.rows = rows; + + // We'll resize after the loop to avoid borrow issues + // For now, just resize to the current client's size unsafe { let winsize = libc::winsize { ws_row: rows, @@ -930,15 +1052,48 @@ impl PtyProcess { // Update client count in status file let _ = Session::update_client_count(&session_id, active_clients.len()); - // Notify remaining clients + // Notify remaining clients and resize to smallest if !active_clients.is_empty() { let notification = format!( "\r\n[A client disconnected (remaining: {})]\r\n", active_clients.len() ); for client in &mut active_clients { - let _ = client.write_all(notification.as_bytes()); - let _ = client.flush(); + let _ = client.stream.write_all(notification.as_bytes()); + let _ = client.stream.flush(); + } + + // Find the smallest terminal size among remaining clients + let mut min_cols = u16::MAX; + let mut min_rows = u16::MAX; + for client in &active_clients { + min_cols = min_cols.min(client.cols); + min_rows = min_rows.min(client.rows); + } + + // Resize the PTY to the smallest size + if min_cols != u16::MAX && min_rows != u16::MAX { + unsafe { + let winsize = libc::winsize { + ws_row: min_rows, + ws_col: min_cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + libc::ioctl( + self.master_fd, + libc::TIOCSWINSZ as u64, + &winsize, + ); + } + + // Send SIGWINCH to notify the shell + let _ = kill(self.pid, Signal::SIGWINCH); + + // Send Ctrl+L to refresh the display + let mut master_file = unsafe { File::from_raw_fd(self.master_fd) }; + let _ = master_file.write_all(b"\x0c"); + std::mem::forget(master_file); } } } From 3e4183380a849aa61d258d5a67e9c4afd537d746 Mon Sep 17 00:00:00 2001 From: Kerem Noras Date: Sat, 13 Sep 2025 12:14:35 +0300 Subject: [PATCH 2/4] refactor: Modularize pty.rs into smaller, maintainable modules Breaking down the monolithic 1139-line pty.rs file into focused modules: Module Structure: - pty/client.rs (38 lines) - Client connection and terminal size management - pty/socket.rs (61 lines) - Unix socket operations and command parsing - pty/terminal.rs (119 lines) - Terminal state save/restore and raw mode - pty/io_handler.rs (172 lines) - PTY I/O operations and scrollback buffer - pty/session_switcher.rs (155 lines) - Interactive session switching logic - pty/spawn.rs (865 lines) - Core PTY process spawning and attachment - pty/mod.rs (14 lines) - Module exports for backward compatibility Benefits: - Improved code organization and maintainability - Better separation of concerns - Faster incremental compilation - Easier testing of individual components - Reduced cognitive load for developers All tests pass and backward compatibility is maintained. --- Cargo.toml | 2 +- src/pty.rs | 1139 ----------------------------------- src/pty/client.rs | 38 ++ src/pty/io_handler.rs | 201 +++++++ src/pty/mod.rs | 14 + src/pty/session_switcher.rs | 159 +++++ src/pty/socket.rs | 60 ++ src/pty/spawn.rs | 866 ++++++++++++++++++++++++++ src/pty/terminal.rs | 127 ++++ tests/integration_test.rs | 2 +- 10 files changed, 1467 insertions(+), 1141 deletions(-) delete mode 100644 src/pty.rs create mode 100644 src/pty/client.rs create mode 100644 src/pty/io_handler.rs create mode 100644 src/pty/mod.rs create mode 100644 src/pty/session_switcher.rs create mode 100644 src/pty/socket.rs create mode 100644 src/pty/spawn.rs create mode 100644 src/pty/terminal.rs diff --git a/Cargo.toml b/Cargo.toml index c50e4b5..7af6809 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "detached-shell" version = "0.1.1" readme = "README.md" edition = "2021" -authors = ["Noras "] +authors = ["Kerem Noras "] description = "Noras.tech's minimalist detachable shell solution ยท zero configuration ยท not a complex multiplexer, just persistent sessions" repository = "https://github.com/NorasTech/detached-shell" homepage = "https://github.com/NorasTech/detached-shell" diff --git a/src/pty.rs b/src/pty.rs deleted file mode 100644 index f4fc403..0000000 --- a/src/pty.rs +++ /dev/null @@ -1,1139 +0,0 @@ -use std::fs::File; -use std::io::{self, Read, Write}; -use std::os::unix::io::{BorrowedFd, FromRawFd, RawFd}; -use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; - -use crossterm::terminal; -use nix::fcntl::{fcntl, FcntlArg, OFlag}; -use nix::sys::signal::{kill, Signal}; -use nix::sys::termios::{tcflush, tcgetattr, tcsetattr, FlushArg, SetArg}; -use nix::unistd::{close, dup2, execvp, fork, setsid, ForkResult, Pid}; - -use crate::error::{NdsError, Result}; -use crate::manager::SessionManager; -use crate::pty_buffer::PtyBuffer; -use crate::scrollback::ScrollbackViewer; -use crate::session::Session; -use crate::terminal_state::TerminalState; - -// Structure to track client information -struct ClientInfo { - stream: UnixStream, - cols: u16, - rows: u16, -} - -pub struct PtyProcess { - pub master_fd: RawFd, - pub pid: Pid, - pub socket_path: PathBuf, - listener: Option, - output_buffer: Option, -} - -impl PtyProcess { - pub fn spawn_new_detached(session_id: &str) -> Result { - Self::spawn_new_detached_with_name(session_id, None) - } - - pub fn spawn_new_detached_with_name(session_id: &str, name: Option) -> Result { - // Capture terminal size BEFORE detaching - let (cols, rows) = terminal::size().unwrap_or((80, 24)); - - // First fork to create intermediate process - match unsafe { fork() } - .map_err(|e| NdsError::ForkError(format!("First fork failed: {}", e)))? - { - ForkResult::Parent { child: _ } => { - // Wait for the intermediate process to complete - std::thread::sleep(std::time::Duration::from_millis(200)); - - // Load the session that was created by the daemon - Session::load(session_id) - } - ForkResult::Child => { - // We're in the intermediate process - // Create a new session to detach from the terminal - setsid().map_err(|e| NdsError::ProcessError(format!("setsid failed: {}", e)))?; - - // Second fork to ensure we can't acquire a controlling terminal - match unsafe { fork() } - .map_err(|e| NdsError::ForkError(format!("Second fork failed: {}", e)))? - { - ForkResult::Parent { child: _ } => { - // Intermediate process exits immediately - std::process::exit(0); - } - ForkResult::Child => { - // We're now in the daemon process - // Close standard file descriptors to fully detach - unsafe { - libc::close(0); - libc::close(1); - libc::close(2); - - // Redirect to /dev/null - let dev_null = libc::open( - b"/dev/null\0".as_ptr() as *const libc::c_char, - libc::O_RDWR, - ); - if dev_null >= 0 { - libc::dup2(dev_null, 0); - libc::dup2(dev_null, 1); - libc::dup2(dev_null, 2); - if dev_null > 2 { - libc::close(dev_null); - } - } - } - - // Continue with PTY setup, passing the captured terminal size - let (pty_process, _session) = - Self::spawn_new_internal_with_size(session_id, name, cols, rows)?; - - // Run the PTY handler - if let Err(_e) = pty_process.run_detached() { - // Can't print errors anymore since stdout is closed - } - - // Clean up when done - Session::cleanup(session_id).ok(); - std::process::exit(0); - } - } - } - } - } - - fn spawn_new_internal_with_size( - session_id: &str, - name: Option, - cols: u16, - rows: u16, - ) -> Result<(Self, Session)> { - // Open PTY using libc directly since nix 0.29 doesn't have pty module - let (master_fd, slave_fd) = Self::open_pty()?; - - // Set terminal size on slave - unsafe { - let winsize = libc::winsize { - ws_row: rows, - ws_col: cols, - ws_xpixel: 0, - ws_ypixel: 0, - }; - if libc::ioctl(slave_fd, libc::TIOCSWINSZ as u64, &winsize) < 0 { - return Err(NdsError::PtyError( - "Failed to set terminal size".to_string(), - )); - } - } - - // Set non-blocking on master - let flags = fcntl(master_fd, FcntlArg::F_GETFL) - .map_err(|e| NdsError::PtyError(format!("Failed to get flags: {}", e)))?; - fcntl( - master_fd, - FcntlArg::F_SETFL(OFlag::from_bits_truncate(flags) | OFlag::O_NONBLOCK), - ) - .map_err(|e| NdsError::PtyError(format!("Failed to set non-blocking: {}", e)))?; - - // Create socket for IPC - let socket_path = Session::socket_dir()?.join(format!("{}.sock", session_id)); - - // Remove socket if it exists - if socket_path.exists() { - std::fs::remove_file(&socket_path)?; - } - - let listener = UnixListener::bind(&socket_path) - .map_err(|e| NdsError::SocketError(format!("Failed to bind socket: {}", e)))?; - - // Fork process - match unsafe { fork() }.map_err(|e| NdsError::ForkError(e.to_string()))? { - ForkResult::Parent { child } => { - // Close slave in parent - let _ = close(slave_fd); - - // Create session metadata - let session = Session::with_name( - session_id.to_string(), - name, - child.as_raw(), - socket_path.clone(), - ); - session.save().map_err(|e| { - eprintln!("Failed to save session: {}", e); - e - })?; - - let pty_process = PtyProcess { - master_fd, - pid: child, - socket_path, - listener: Some(listener), - output_buffer: Some(PtyBuffer::new(1024 * 1024)), // 1MB buffer - }; - - Ok((pty_process, session)) - } - ForkResult::Child => { - // Close master in child - let _ = close(master_fd); - - // Create new session - setsid().map_err(|e| NdsError::ProcessError(format!("setsid failed: {}", e)))?; - - // Make slave the controlling terminal - unsafe { - if libc::ioctl(slave_fd, libc::TIOCSCTTY as u64, 0) < 0 { - eprintln!("Failed to set controlling terminal"); - std::process::exit(1); - } - } - - // Duplicate slave to stdin/stdout/stderr - dup2(slave_fd, 0) - .map_err(|e| NdsError::ProcessError(format!("dup2 stdin failed: {}", e)))?; - dup2(slave_fd, 1) - .map_err(|e| NdsError::ProcessError(format!("dup2 stdout failed: {}", e)))?; - dup2(slave_fd, 2) - .map_err(|e| NdsError::ProcessError(format!("dup2 stderr failed: {}", e)))?; - - // Close original slave - if slave_fd > 2 { - let _ = close(slave_fd); - } - - // Set environment variables for session tracking - std::env::set_var("NDS_SESSION_ID", session_id); - if let Some(ref session_name) = name { - std::env::set_var("NDS_SESSION_NAME", session_name); - } else { - std::env::set_var("NDS_SESSION_NAME", session_id); - } - - // Get shell - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - - // Execute shell - let shell_cstr = std::ffi::CString::new(shell.as_str()).unwrap(); - let args = vec![shell_cstr.clone()]; - - execvp(&shell_cstr, &args) - .map_err(|e| NdsError::ProcessError(format!("execvp failed: {}", e)))?; - - // Should never reach here - unreachable!() - } - } - } - - fn open_pty() -> Result<(RawFd, RawFd)> { - unsafe { - // Open PTY master - let master_fd = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY); - if master_fd < 0 { - return Err(NdsError::PtyError("Failed to open PTY master".to_string())); - } - - // Grant access to slave - if libc::grantpt(master_fd) < 0 { - let _ = libc::close(master_fd); - return Err(NdsError::PtyError("Failed to grant PTY access".to_string())); - } - - // Unlock slave - if libc::unlockpt(master_fd) < 0 { - let _ = libc::close(master_fd); - return Err(NdsError::PtyError("Failed to unlock PTY".to_string())); - } - - // Get slave name - let slave_name = libc::ptsname(master_fd); - if slave_name.is_null() { - let _ = libc::close(master_fd); - return Err(NdsError::PtyError( - "Failed to get PTY slave name".to_string(), - )); - } - - // Open slave - let slave_cstr = std::ffi::CStr::from_ptr(slave_name); - let slave_fd = libc::open(slave_cstr.as_ptr(), libc::O_RDWR); - if slave_fd < 0 { - let _ = libc::close(master_fd); - return Err(NdsError::PtyError("Failed to open PTY slave".to_string())); - } - - Ok((master_fd, slave_fd)) - } - } - - pub fn attach_to_session(session: &Session) -> Result> { - // Set environment variable to track current attached session - std::env::set_var("NDS_SESSION_ID", &session.id); - std::env::set_var("NDS_SESSION_NAME", session.name.as_ref().unwrap_or(&session.id)); - - // Save current terminal state - let stdin_fd = 0; - let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - let original_termios = tcgetattr(&stdin).map_err(|e| { - NdsError::TerminalError(format!("Failed to get terminal attributes: {}", e)) - })?; - - // Capture current terminal state - let _terminal_state = TerminalState::capture(stdin_fd)?; - - // Connect to session socket - let mut socket = session.connect_socket()?; - - // Get current terminal size and send resize command - let (cols, rows) = terminal::size().map_err(|e| NdsError::TerminalError(e.to_string()))?; - - // Send a special resize command to the daemon - // Format: \x1b]nds:resize::\x07 - let resize_cmd = format!("\x1b]nds:resize:{}:{}\x07", cols, rows); - let _ = socket.write_all(resize_cmd.as_bytes()); - let _ = socket.flush(); - - // Small delay to let the resize happen - thread::sleep(std::time::Duration::from_millis(50)); - - // Send a sequence to help restore the terminal state - // First, send Ctrl+L to refresh the display - let _ = socket.write_all(b"\x0c"); - let _ = socket.flush(); - - // Small delay to let the refresh happen - thread::sleep(std::time::Duration::from_millis(50)); - - // Set terminal to raw mode - let mut raw = original_termios.clone(); - // Manually set raw mode flags - raw.input_flags = nix::sys::termios::InputFlags::empty(); - raw.output_flags = nix::sys::termios::OutputFlags::empty(); - raw.control_flags |= nix::sys::termios::ControlFlags::CS8; - raw.local_flags = nix::sys::termios::LocalFlags::empty(); - raw.control_chars[nix::sys::termios::SpecialCharacterIndices::VMIN as usize] = 1; - raw.control_chars[nix::sys::termios::SpecialCharacterIndices::VTIME as usize] = 0; - tcsetattr(&stdin, SetArg::TCSANOW, &raw) - .map_err(|e| NdsError::TerminalError(format!("Failed to set raw mode: {}", e)))?; - - // Create a flag for clean shutdown - let running = Arc::new(AtomicBool::new(true)); - let r1 = running.clone(); - let r2 = running.clone(); - - // Handle Ctrl+C - ctrlc::set_handler(move || { - r1.store(false, Ordering::SeqCst); - }) - .map_err(|e| NdsError::SignalError(format!("Failed to set signal handler: {}", e)))?; - - println!("\r\n[Attached to session {}]\r", session.id); - println!("[Press Enter then ~d to detach, ~s to switch, ~h for history]\r"); - - // Scrollback buffer (max 10MB) - let scrollback_buffer = Arc::new(std::sync::Mutex::new(Vec::::new())); - - // Spawn thread to monitor terminal size changes - let socket_for_resize = socket - .try_clone() - .map_err(|e| NdsError::SocketError(format!("Failed to clone socket: {}", e)))?; - let resize_running = running.clone(); - let _resize_monitor = thread::spawn(move || { - let mut last_size = (cols, rows); - let mut socket = socket_for_resize; - - while resize_running.load(Ordering::SeqCst) { - if let Ok((new_cols, new_rows)) = terminal::size() { - if (new_cols, new_rows) != last_size { - // Terminal size changed, send resize command - let resize_cmd = format!("\x1b]nds:resize:{}:{}\x07", new_cols, new_rows); - let _ = socket.write_all(resize_cmd.as_bytes()); - let _ = socket.flush(); - last_size = (new_cols, new_rows); - } - } - thread::sleep(std::time::Duration::from_millis(250)); - } - }); - - // Spawn thread to read from socket and write to stdout - let socket_clone = socket - .try_clone() - .map_err(|e| NdsError::SocketError(format!("Failed to clone socket: {}", e)))?; - let scrollback_clone = Arc::clone(&scrollback_buffer); - let socket_to_stdout = thread::spawn(move || { - let mut socket = socket_clone; - let mut stdout = io::stdout(); - let mut buffer = [0u8; 4096]; - - while r2.load(Ordering::SeqCst) { - match socket.read(&mut buffer) { - Ok(0) => break, // Socket closed - Ok(n) => { - // Write to stdout - if let Err(_) = stdout.write_all(&buffer[..n]) { - break; - } - let _ = stdout.flush(); - - // Add to scrollback buffer - let mut scrollback = scrollback_clone.lock().unwrap(); - scrollback.extend_from_slice(&buffer[..n]); - - // Trim if too large - let scrollback_max = 10 * 1024 * 1024; - if scrollback.len() > scrollback_max { - let remove = scrollback.len() - scrollback_max; - scrollback.drain(..remove); - } - } - Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { - thread::sleep(std::time::Duration::from_millis(10)); - } - Err(ref e) if e.kind() == io::ErrorKind::BrokenPipe => { - // Expected when socket is closed, just exit cleanly - break; - } - Err(_) => break, - } - } - }); - - // Read from stdin and write to socket - let stdin_fd = 0i32; - - // Make stdin non-blocking - unsafe { - let flags = libc::fcntl(stdin_fd, libc::F_GETFL); - libc::fcntl(stdin_fd, libc::F_SETFL, flags | libc::O_NONBLOCK); - } - - let mut stdin = io::stdin(); - let mut buffer = [0u8; 1024]; - - // SSH-style escape sequence: Enter ~d - // We'll track if we're at the beginning of a line - let mut at_line_start = true; - let mut escape_state = 0; // 0=normal, 1=saw tilde at line start - let mut escape_time = std::time::Instant::now(); - - loop { - if !running.load(Ordering::SeqCst) { - break; - } - - match stdin.read(&mut buffer) { - Ok(0) => { - // EOF (Ctrl+D) - treat as detach, not session termination - // Do NOT forward EOF to session as it would terminate the shell - println!("\r\n[Detaching from session {}]\r", session.id); - running.store(false, Ordering::SeqCst); - break; - }, - Ok(n) => { - let mut should_detach = false; - let mut should_switch = false; - let mut should_scroll = false; - let mut data_to_forward = Vec::new(); - - // Check for escape timeout (reset after 1 second) - if escape_state == 1 - && escape_time.elapsed() > std::time::Duration::from_secs(1) - { - // Timeout - forward the held tilde and reset - data_to_forward.push(b'~'); - escape_state = 0; - } - - // Process each byte for escape sequence - for i in 0..n { - let byte = buffer[i]; - - // Check for Ctrl+D (ASCII 4) - detach this client only - if byte == 0x04 { - should_detach = true; - break; - } - - match escape_state { - 0 => { - // Normal state - if at_line_start && byte == b'~' { - // Start of potential escape sequence - escape_state = 1; - escape_time = std::time::Instant::now(); - // Don't forward the tilde yet - } else { - // Regular character - data_to_forward.push(byte); - // Update line start tracking - at_line_start = - byte == b'\r' || byte == b'\n' || byte == 10 || byte == 13; - } - } - 1 => { - // We saw ~ at the beginning of a line - if byte == b'd' { - // Detach command ~d - should_detach = true; - break; - } else if byte == b's' { - // Switch sessions command ~s - should_switch = true; - break; - } else if byte == b'h' { - // History/scrollback command ~h - should_scroll = true; - break; - } else if byte == b'~' { - // ~~ means literal tilde - data_to_forward.push(b'~'); - escape_state = 0; - at_line_start = false; - } else { - // Not an escape sequence, forward tilde and this char - data_to_forward.push(b'~'); - data_to_forward.push(byte); - escape_state = 0; - at_line_start = - byte == b'\r' || byte == b'\n' || byte == 10 || byte == 13; - } - } - _ => { - escape_state = 0; - } - } - } - - if should_detach { - println!("\r\n[Detaching from session {}]\r", session.id); - running.store(false, Ordering::SeqCst); - break; - } - - if should_switch { - // Show session switcher - println!("\r\n[Session Switcher]\r"); - - // Get list of other sessions - match SessionManager::list_sessions() { - Ok(sessions) => { - let other_sessions: Vec<_> = - sessions.iter().filter(|s| s.id != session.id).collect(); - - // Show available sessions - println!("\r\nAvailable options:\r"); - - // Show existing sessions - if !other_sessions.is_empty() { - for (i, s) in other_sessions.iter().enumerate() { - println!( - "\r {}. {} (PID: {})\r", - i + 1, - s.display_name(), - s.pid - ); - } - } - - // Add new session option - let new_option_num = other_sessions.len() + 1; - println!("\r {}. [New Session]\r", new_option_num); - println!("\r 0. Cancel\r"); - println!("\r\nSelect option (0-{}): ", new_option_num); - let _ = io::stdout().flush(); - - // Temporarily restore terminal to cooked mode for input - let stdin_fd = 0; - let stdin_borrowed = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - - // Save current raw mode settings - let current_termios = tcgetattr(&stdin_borrowed)?; - - // Restore to original (cooked) mode for line input - tcsetattr(&stdin_borrowed, SetArg::TCSANOW, &original_termios)?; - - // Read user selection - let mut selection = String::new(); - let read_result = io::stdin().read_line(&mut selection); - - // Restore raw mode - tcsetattr(&stdin_borrowed, SetArg::TCSANOW, ¤t_termios)?; - - if let Ok(_) = read_result { - if let Ok(num) = selection.trim().parse::() { - if num > 0 && num <= other_sessions.len() { - // Switch to selected session - let target_session = other_sessions[num - 1]; - println!( - "\r\n[Switching to session {}]\r", - target_session.id - ); - - // Store the target session ID for return - return Ok(Some(target_session.id.clone())); - } else if num == new_option_num { - // Create new session - println!("\r\nEnter name for new session (or press Enter for no name): "); - let _ = io::stdout().flush(); - - // Temporarily restore terminal to cooked mode for input - let stdin_fd = 0; - let stdin_borrowed = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - - // Save current raw mode settings - let current_termios = tcgetattr(&stdin_borrowed)?; - - // Restore to original (cooked) mode for line input - tcsetattr(&stdin_borrowed, SetArg::TCSANOW, &original_termios)?; - - let mut session_name = String::new(); - let read_result = io::stdin().read_line(&mut session_name); - - // Restore raw mode - tcsetattr(&stdin_borrowed, SetArg::TCSANOW, ¤t_termios)?; - - if let Ok(_) = read_result - { - let session_name = session_name.trim(); - let name = if session_name.is_empty() { - None - } else { - Some(session_name.to_string()) - }; - - // Create new session - match SessionManager::create_session_with_name( - name.clone(), - ) { - Ok(new_session) => { - if let Some(ref n) = name { - println!("\r\n[Created and switching to new session '{}' ({})]", n, new_session.id); - } else { - println!("\r\n[Created and switching to new session {}]", new_session.id); - } - - // Return the new session ID to switch to it - return Ok(Some(new_session.id.clone())); - } - Err(e) => { - eprintln!( - "\r\nError creating session: {}\r", - e - ); - } - } - } - } - } - } - - // Cancelled or invalid selection - println!("\r\n[Continuing current session]\r"); - escape_state = 0; - at_line_start = true; - } - Err(e) => { - eprintln!("\r\nError listing sessions: {}\r", e); - escape_state = 0; - at_line_start = true; - } - } - } - - if should_scroll { - // Show scrollback viewer - println!("\r\n[Opening scrollback viewer...]\r"); - - // Get scrollback content - let content = scrollback_buffer.lock().unwrap().clone(); - - // Temporarily restore terminal for viewer - let stdin_fd = 0; - let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - tcsetattr(&stdin, SetArg::TCSANOW, &original_termios).map_err(|e| { - NdsError::TerminalError(format!("Failed to restore terminal: {}", e)) - })?; - - // Show scrollback viewer - let mut viewer = ScrollbackViewer::new(&content); - let _ = viewer.run(); // Ignore errors, just return to session - - // Re-enter raw mode - tcsetattr(&stdin, SetArg::TCSANOW, &raw).map_err(|e| { - NdsError::TerminalError(format!("Failed to set raw mode: {}", e)) - })?; - - // Refresh display - let _ = socket.write_all(b"\x0c"); // Ctrl+L - let _ = socket.flush(); - - println!("\r\n[Returned to session]\r"); - - // Reset state - escape_state = 0; - at_line_start = true; - } - - // Forward the processed data - if !data_to_forward.is_empty() { - if let Err(e) = socket.write_all(&data_to_forward) { - // Check if it's a broken pipe (expected on detach) - if e.kind() == io::ErrorKind::BrokenPipe { - // This is expected when detaching, just break - break; - } else { - eprintln!("\r\nError writing to socket: {}\r", e); - break; - } - } - } - } - Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { - // No input available, check if we should exit - if !running.load(Ordering::SeqCst) { - break; - } - thread::sleep(std::time::Duration::from_millis(10)); - continue; - } - Err(e) => { - eprintln!("\r\nError reading stdin: {}\r", e); - break; - } - } - } - - // Stop the socket reader thread - running.store(false, Ordering::SeqCst); - - // Shutdown and close the socket to immediately unblock the reader thread - let _ = socket.shutdown(std::net::Shutdown::Both); - drop(socket); - - // Wait for the thread with a shorter timeout since we shutdown the socket - thread::sleep(std::time::Duration::from_millis(50)); - let _ = socket_to_stdout.join(); - - // Restore terminal - do this BEFORE any output - let stdin_fd = 0; - let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - - // First restore stdin to blocking mode - unsafe { - let flags = libc::fcntl(stdin_fd, libc::F_GETFL); - libc::fcntl(stdin_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); - } - - // Clear any pending input from stdin buffer - tcflush(&stdin, FlushArg::TCIFLUSH) - .map_err(|e| NdsError::TerminalError(format!("Failed to flush stdin: {}", e)))?; - - // Restore the terminal settings - tcsetattr(&stdin, SetArg::TCSANOW, &original_termios) - .map_err(|e| NdsError::TerminalError(format!("Failed to restore terminal: {}", e)))?; - - // Ensure we're back in cooked mode - terminal::disable_raw_mode().ok(); - - // Clear any remaining input after terminal restore - tcflush(&stdin, FlushArg::TCIFLUSH) - .map_err(|e| NdsError::TerminalError(format!("Failed to flush stdin after restore: {}", e)))?; - - // Add a small delay to ensure terminal is fully restored - thread::sleep(std::time::Duration::from_millis(50)); - - // Clear environment variables on detach - std::env::remove_var("NDS_SESSION_ID"); - std::env::remove_var("NDS_SESSION_NAME"); - - // Now it's safe to print the detach message - println!("\n[Detached from session {}]", session.id); - - // Flush stdout to ensure message is displayed - let _ = io::stdout().flush(); - - // Clear any pending input from stdin to prevent hanging - tcflush(&stdin, nix::sys::termios::FlushArg::TCIFLUSH).ok(); - - Ok(None) - } - - pub fn run_detached(mut self) -> Result<()> { - let listener = self - .listener - .take() - .ok_or_else(|| NdsError::PtyError("No listener available".to_string()))?; - - // Set listener to non-blocking - listener.set_nonblocking(true)?; - - let running = Arc::new(AtomicBool::new(true)); - let r = running.clone(); - - // Handle cleanup on exit - ctrlc::set_handler(move || { - r.store(false, Ordering::SeqCst); - }) - .map_err(|e| NdsError::SignalError(format!("Failed to set signal handler: {}", e)))?; - - let output_buffer = self - .output_buffer - .take() - .ok_or_else(|| NdsError::PtyError("No output buffer available".to_string()))?; - - // Support multiple concurrent clients with their terminal sizes - let mut active_clients: Vec = Vec::new(); - let mut buffer = [0u8; 4096]; - - // Get session ID from socket path - let session_id = self - .socket_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - - while running.load(Ordering::SeqCst) { - // Check for new connections - match listener.accept() { - Ok((mut stream, _)) => { - stream.set_nonblocking(true)?; - - // Notify existing clients about new connection - let notification = format!( - "\r\n[Another client connected to this session (total: {})]\r\n", - active_clients.len() + 1 - ); - for client in &mut active_clients { - let _ = client.stream.write_all(notification.as_bytes()); - let _ = client.stream.flush(); - } - - // Send buffered output to new client - if !output_buffer.is_empty() { - let mut buffered_data = Vec::new(); - output_buffer.drain_to(&mut buffered_data); - - // Save cursor position, clear screen, and reset - let init_sequence = b"\x1b7\x1b[?47h\x1b[2J\x1b[H"; // Save cursor, alt screen, clear, home - let _ = stream.write_all(init_sequence); - let _ = stream.flush(); - - // Send buffered data in chunks to avoid overwhelming the client - for chunk in buffered_data.chunks(4096) { - let _ = stream.write_all(chunk); - let _ = stream.flush(); - std::thread::sleep(std::time::Duration::from_millis(1)); - } - - // Exit alt screen and restore cursor - let restore_sequence = b"\x1b[?47l\x1b8"; // Exit alt screen, restore cursor - let _ = stream.write_all(restore_sequence); - let _ = stream.flush(); - - // Small delay for terminal to process - std::thread::sleep(std::time::Duration::from_millis(50)); - - // Send a full redraw command to the shell - let mut master_file = unsafe { File::from_raw_fd(self.master_fd) }; - let _ = master_file.write_all(b"\x0c"); // Ctrl+L to refresh - std::mem::forget(master_file); // Don't close the fd - - // Give time for the refresh to complete - std::thread::sleep(std::time::Duration::from_millis(100)); - } else { - // No buffer, just request a refresh to sync state - let mut master_file = unsafe { File::from_raw_fd(self.master_fd) }; - let _ = master_file.write_all(b"\x0c"); // Ctrl+L to refresh - std::mem::forget(master_file); // Don't close the fd - } - - // Add new client to the list - active_clients.push(ClientInfo { - stream, - cols: 80, - rows: 24, - }); - - // Update client count in status file - let _ = Session::update_client_count(&session_id, active_clients.len()); - } - Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { - // No new connections - } - Err(_e) => { - // Error accepting connection, continue - } - } - - // Read from PTY master - let master_file = unsafe { File::from_raw_fd(self.master_fd) }; - let mut master_file_clone = master_file.try_clone()?; - std::mem::forget(master_file); // Don't close the fd - - match master_file_clone.read(&mut buffer) { - Ok(0) => { - // Child process exited - break; - } - Ok(n) => { - let data = &buffer[..n]; - - // Broadcast to all connected clients - if !active_clients.is_empty() { - let mut disconnected_indices = Vec::new(); - - for (i, client) in active_clients.iter_mut().enumerate() { - if let Err(e) = client.stream.write_all(data) { - if e.kind() == io::ErrorKind::BrokenPipe - || e.kind() == io::ErrorKind::ConnectionAborted - { - // Mark client for removal - disconnected_indices.push(i); - } - } else { - let _ = client.stream.flush(); - } - } - - // Remove disconnected clients and notify others - if !disconnected_indices.is_empty() { - for i in disconnected_indices.iter().rev() { - active_clients.remove(*i); - } - - // Update client count in status file - let _ = Session::update_client_count(&session_id, active_clients.len()); - - // Notify remaining clients and refresh their terminals - if !active_clients.is_empty() { - let notification = format!( - "\r\n[A client disconnected (remaining: {})]\r\n", - active_clients.len() - ); - - // Terminal refresh sequences - let refresh_sequences = [ - "\x1b[?25h", // Show cursor - "\x1b[?12h", // Enable cursor blinking - "\x1b[1 q", // Blinking block cursor (default) - "\x1b[m", // Reset all attributes - "\x1b[?1000l", // Disable mouse tracking (if enabled) - "\x1b[?1002l", // Disable cell motion mouse tracking - "\x1b[?1003l", // Disable all motion mouse tracking - ].join(""); - - for client in &mut active_clients { - let _ = client.stream.write_all(notification.as_bytes()); - let _ = client.stream.write_all(refresh_sequences.as_bytes()); - let _ = client.stream.flush(); - } - } - } - - // If all clients disconnected, start buffering - if active_clients.is_empty() { - output_buffer.push(data); - } - } else { - // No clients connected, buffer the output - output_buffer.push(data); - } - } - Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { - // No data available - } - Err(_) => { - // Other error, continue - } - } - - // Read from clients and write to PTY - let mut disconnected_indices = Vec::new(); - - for (i, client) in active_clients.iter_mut().enumerate() { - let mut client_buffer = [0u8; 1024]; - match client.stream.read(&mut client_buffer) { - Ok(0) => { - // Client disconnected - disconnected_indices.push(i); - } - Ok(n) => { - let data = &client_buffer[..n]; - - // Check for special NDS commands - // Format: \x1b]nds:resize::\x07 - if n > 10 && data.starts_with(b"\x1b]nds:") { - if let Ok(cmd_str) = std::str::from_utf8(data) { - if let Some(end_idx) = cmd_str.find('\x07') { - let cmd = &cmd_str[2..end_idx]; // Skip \x1b] - if cmd.starts_with("nds:resize:") { - // Parse resize command - let parts: Vec<&str> = - cmd["nds:resize:".len()..].split(':').collect(); - if parts.len() == 2 { - if let (Ok(cols), Ok(rows)) = - (parts[0].parse::(), parts[1].parse::()) - { - // Update this client's terminal size - client.cols = cols; - client.rows = rows; - - // We'll resize after the loop to avoid borrow issues - // For now, just resize to the current client's size - unsafe { - let winsize = libc::winsize { - ws_row: rows, - ws_col: cols, - ws_xpixel: 0, - ws_ypixel: 0, - }; - libc::ioctl( - self.master_fd, - libc::TIOCSWINSZ as u64, - &winsize, - ); - } - - // Send SIGWINCH to the child process to notify of resize - let _ = kill(self.pid, Signal::SIGWINCH); - - // Don't forward the resize command to the PTY - // But forward any remaining data after the command - if end_idx + 1 < n { - let remaining = &data[end_idx + 1..]; - if !remaining.is_empty() { - let mut master_file = unsafe { - File::from_raw_fd(self.master_fd) - }; - let _ = master_file.write_all(remaining); - std::mem::forget(master_file); - // Don't close the fd - } - } - continue; // Skip normal forwarding - } - } - } - } - } - } - - // Normal data - forward to PTY - let mut master_file = unsafe { File::from_raw_fd(self.master_fd) }; - let _ = master_file.write_all(data); - std::mem::forget(master_file); // Don't close the fd - } - Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { - // No data available - } - Err(_) => { - // Client error, mark for removal - disconnected_indices.push(i); - } - } - } - - // Remove disconnected clients and notify others - if !disconnected_indices.is_empty() { - for i in disconnected_indices.iter().rev() { - active_clients.remove(*i); - } - - // Update client count in status file - let _ = Session::update_client_count(&session_id, active_clients.len()); - - // Notify remaining clients and resize to smallest - if !active_clients.is_empty() { - let notification = format!( - "\r\n[A client disconnected (remaining: {})]\r\n", - active_clients.len() - ); - for client in &mut active_clients { - let _ = client.stream.write_all(notification.as_bytes()); - let _ = client.stream.flush(); - } - - // Find the smallest terminal size among remaining clients - let mut min_cols = u16::MAX; - let mut min_rows = u16::MAX; - for client in &active_clients { - min_cols = min_cols.min(client.cols); - min_rows = min_rows.min(client.rows); - } - - // Resize the PTY to the smallest size - if min_cols != u16::MAX && min_rows != u16::MAX { - unsafe { - let winsize = libc::winsize { - ws_row: min_rows, - ws_col: min_cols, - ws_xpixel: 0, - ws_ypixel: 0, - }; - libc::ioctl( - self.master_fd, - libc::TIOCSWINSZ as u64, - &winsize, - ); - } - - // Send SIGWINCH to notify the shell - let _ = kill(self.pid, Signal::SIGWINCH); - - // Send Ctrl+L to refresh the display - let mut master_file = unsafe { File::from_raw_fd(self.master_fd) }; - let _ = master_file.write_all(b"\x0c"); - std::mem::forget(master_file); - } - } - } - - // Small sleep to prevent busy loop - thread::sleep(std::time::Duration::from_millis(10)); - } - - Ok(()) - } - - pub fn kill_session(session_id: &str) -> Result<()> { - let session = Session::load(session_id)?; - - // Send SIGTERM to the process - kill(Pid::from_raw(session.pid), Signal::SIGTERM) - .map_err(|e| NdsError::ProcessError(format!("Failed to kill process: {}", e)))?; - - // Wait a moment for graceful shutdown - thread::sleep(std::time::Duration::from_millis(500)); - - // Force kill if still alive - if Session::is_process_alive(session.pid) { - kill(Pid::from_raw(session.pid), Signal::SIGKILL).map_err(|e| { - NdsError::ProcessError(format!("Failed to force kill process: {}", e)) - })?; - } - - // Clean up session files - Session::cleanup(session_id)?; - - Ok(()) - } -} - -impl Drop for PtyProcess { - fn drop(&mut self) { - let _ = close(self.master_fd); - if let Some(listener) = self.listener.take() { - drop(listener); - } - } -} diff --git a/src/pty/client.rs b/src/pty/client.rs new file mode 100644 index 0000000..7b017ae --- /dev/null +++ b/src/pty/client.rs @@ -0,0 +1,38 @@ +use std::os::unix::net::UnixStream; +use nix::libc; + +// Structure to track client information +#[derive(Debug)] +pub struct ClientInfo { + pub stream: UnixStream, + pub rows: u16, + pub cols: u16, +} + +impl ClientInfo { + pub fn new(stream: UnixStream) -> Self { + // Get initial terminal size + let (rows, cols) = get_terminal_size().unwrap_or((24, 80)); + + Self { + stream, + rows, + cols, + } + } + + pub fn update_size(&mut self, rows: u16, cols: u16) { + self.rows = rows; + self.cols = cols; + } +} + +pub fn get_terminal_size() -> Result<(u16, u16), std::io::Error> { + unsafe { + let mut size: libc::winsize = std::mem::zeroed(); + if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut size) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok((size.ws_row, size.ws_col)) + } +} \ No newline at end of file diff --git a/src/pty/io_handler.rs b/src/pty/io_handler.rs new file mode 100644 index 0000000..761040a --- /dev/null +++ b/src/pty/io_handler.rs @@ -0,0 +1,201 @@ +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::unix::io::{FromRawFd, RawFd}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use std::thread; +use std::time::Duration; + +use crate::pty_buffer::PtyBuffer; + +/// Handle reading from PTY master and broadcasting to clients +pub struct PtyIoHandler { + master_fd: RawFd, + buffer_size: usize, +} + +impl PtyIoHandler { + pub fn new(master_fd: RawFd) -> Self { + Self { + master_fd, + buffer_size: 4096, + } + } + + /// Read from PTY master file descriptor + pub fn read_from_pty(&self, buffer: &mut [u8]) -> io::Result { + let master_file = unsafe { File::from_raw_fd(self.master_fd) }; + let mut master_file_clone = master_file.try_clone()?; + std::mem::forget(master_file); // Don't close the fd + + master_file_clone.read(buffer) + } + + /// Write to PTY master file descriptor + pub fn write_to_pty(&self, data: &[u8]) -> io::Result<()> { + let mut master_file = unsafe { File::from_raw_fd(self.master_fd) }; + let result = master_file.write_all(data); + std::mem::forget(master_file); // Don't close the fd + result + } + + /// Send a control character to the PTY + pub fn send_control_char(&self, ch: u8) -> io::Result<()> { + self.write_to_pty(&[ch]) + } + + /// Send Ctrl+L to refresh the display + pub fn send_refresh(&self) -> io::Result<()> { + self.send_control_char(0x0c) // Ctrl+L + } +} + +/// Handle scrollback buffer management +pub struct ScrollbackHandler { + buffer: Arc>>, + max_size: usize, +} + +impl ScrollbackHandler { + pub fn new(max_size: usize) -> Self { + Self { + buffer: Arc::new(Mutex::new(Vec::new())), + max_size, + } + } + + /// Add data to the scrollback buffer + pub fn add_data(&self, data: &[u8]) { + let mut buffer = self.buffer.lock().unwrap(); + buffer.extend_from_slice(data); + + // Trim if too large + if buffer.len() > self.max_size { + let remove = buffer.len() - self.max_size; + buffer.drain(..remove); + } + } + + /// Get a clone of the scrollback buffer + pub fn get_buffer(&self) -> Vec { + self.buffer.lock().unwrap().clone() + } + + /// Get a reference to the shared buffer + pub fn get_shared_buffer(&self) -> Arc>> { + Arc::clone(&self.buffer) + } +} + +/// Thread that reads from socket and writes to stdout +pub fn spawn_socket_to_stdout_thread( + mut socket: std::os::unix::net::UnixStream, + running: Arc, + scrollback: Arc>>, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let mut stdout = io::stdout(); + let mut buffer = [0u8; 4096]; + + while running.load(Ordering::SeqCst) { + match socket.read(&mut buffer) { + Ok(0) => break, // Socket closed + Ok(n) => { + // Write to stdout + if stdout.write_all(&buffer[..n]).is_err() { + break; + } + let _ = stdout.flush(); + + // Add to scrollback buffer + let mut scrollback = scrollback.lock().unwrap(); + scrollback.extend_from_slice(&buffer[..n]); + + // Trim if too large + let scrollback_max = 10 * 1024 * 1024; // 10MB + if scrollback.len() > scrollback_max { + let remove = scrollback.len() - scrollback_max; + scrollback.drain(..remove); + } + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(ref e) if e.kind() == io::ErrorKind::BrokenPipe => { + // Expected when socket is closed, just exit cleanly + break; + } + Err(_) => break, + } + } + }) +} + +/// Thread that monitors terminal size changes +pub fn spawn_resize_monitor_thread( + mut socket: std::os::unix::net::UnixStream, + running: Arc, + initial_size: (u16, u16), +) -> thread::JoinHandle<()> { + use crate::pty::socket::send_resize_command; + use crossterm::terminal; + + thread::spawn(move || { + let mut last_size = initial_size; + + while running.load(Ordering::SeqCst) { + if let Ok((new_cols, new_rows)) = terminal::size() { + if (new_cols, new_rows) != last_size { + // Terminal size changed, send resize command + let _ = send_resize_command(&mut socket, new_cols, new_rows); + last_size = (new_cols, new_rows); + } + } + thread::sleep(Duration::from_millis(250)); + } + }) +} + +/// Helper to send buffered output to a new client +pub fn send_buffered_output( + stream: &mut std::os::unix::net::UnixStream, + output_buffer: &PtyBuffer, + io_handler: &PtyIoHandler, +) -> io::Result<()> { + if !output_buffer.is_empty() { + let mut buffered_data = Vec::new(); + output_buffer.drain_to(&mut buffered_data); + + // Save cursor position, clear screen, and reset + let init_sequence = b"\x1b7\x1b[?47h\x1b[2J\x1b[H"; // Save cursor, alt screen, clear, home + stream.write_all(init_sequence)?; + stream.flush()?; + + // Send buffered data in chunks to avoid overwhelming the client + for chunk in buffered_data.chunks(4096) { + stream.write_all(chunk)?; + stream.flush()?; + thread::sleep(Duration::from_millis(1)); + } + + // Exit alt screen and restore cursor + let restore_sequence = b"\x1b[?47l\x1b8"; // Exit alt screen, restore cursor + stream.write_all(restore_sequence)?; + stream.flush()?; + + // Small delay for terminal to process + thread::sleep(Duration::from_millis(50)); + + // Send a full redraw command to the shell + io_handler.send_refresh()?; + + // Give time for the refresh to complete + thread::sleep(Duration::from_millis(100)); + } else { + // No buffer, just request a refresh to sync state + io_handler.send_refresh()?; + } + + Ok(()) +} \ No newline at end of file diff --git a/src/pty/mod.rs b/src/pty/mod.rs new file mode 100644 index 0000000..85e4de1 --- /dev/null +++ b/src/pty/mod.rs @@ -0,0 +1,14 @@ +// PTY process management module +mod client; +mod socket; +mod terminal; +mod io_handler; +mod session_switcher; +mod spawn; + +// Re-export main types for backward compatibility +pub use spawn::PtyProcess; + +// Note: ClientInfo is now internal to the module +// If it needs to be public, uncomment the line below: +// pub use client::ClientInfo; \ No newline at end of file diff --git a/src/pty/session_switcher.rs b/src/pty/session_switcher.rs new file mode 100644 index 0000000..b1543ab --- /dev/null +++ b/src/pty/session_switcher.rs @@ -0,0 +1,159 @@ +use std::io::{self, Write, BufRead}; +use std::os::unix::io::{BorrowedFd, RawFd}; + +use nix::sys::termios::{tcgetattr, tcsetattr, SetArg, Termios}; + +use crate::error::{NdsError, Result}; +use crate::session::Session; +use crate::manager::SessionManager; + +/// Result of a session switch operation +pub enum SwitchResult { + /// Switch to an existing session with the given ID + SwitchTo(String), + /// Continue with the current session + Continue, +} + +/// Handle the session switcher interface +pub struct SessionSwitcher<'a> { + current_session: &'a Session, + stdin_fd: RawFd, + original_termios: &'a Termios, +} + +impl<'a> SessionSwitcher<'a> { + pub fn new( + current_session: &'a Session, + stdin_fd: RawFd, + original_termios: &'a Termios, + ) -> Self { + Self { + current_session, + stdin_fd, + original_termios, + } + } + + /// Show the session switcher interface and handle user selection + pub fn show_switcher(&self) -> Result { + println!("\r\n[Session Switcher]\r"); + + // Get list of other sessions + let sessions = SessionManager::list_sessions()?; + let other_sessions: Vec<_> = sessions + .iter() + .filter(|s| s.id != self.current_session.id) + .collect(); + + // Show available sessions + println!("\r\nAvailable options:\r"); + + // Show existing sessions + if !other_sessions.is_empty() { + for (i, s) in other_sessions.iter().enumerate() { + println!( + "\r {}. {} (PID: {})\r", + i + 1, + s.display_name(), + s.pid + ); + } + } + + // Add new session option + let new_option_num = other_sessions.len() + 1; + println!("\r {}. [New Session]\r", new_option_num); + println!("\r 0. Cancel\r"); + println!("\r\nSelect option (0-{}): ", new_option_num); + let _ = io::stdout().flush(); + + // Read user selection with temporary cooked mode + let selection = self.read_user_input()?; + + if let Ok(num) = selection.trim().parse::() { + if num > 0 && num <= other_sessions.len() { + // Switch to selected session + let target_session = other_sessions[num - 1]; + println!("\r\n[Switching to session {}]\r", target_session.id); + return Ok(SwitchResult::SwitchTo(target_session.id.clone())); + } else if num == new_option_num { + // Create new session + return self.handle_new_session(); + } + } + + // Cancelled or invalid selection + println!("\r\n[Continuing current session]\r"); + Ok(SwitchResult::Continue) + } + + /// Handle creating a new session + fn handle_new_session(&self) -> Result { + println!("\r\nEnter name for new session (or press Enter for no name): "); + let _ = io::stdout().flush(); + + let session_name = self.read_user_input()?; + let session_name = session_name.trim(); + + let name = if session_name.is_empty() { + None + } else { + Some(session_name.to_string()) + }; + + // Create new session + match SessionManager::create_session_with_name(name.clone()) { + Ok(new_session) => { + if let Some(ref n) = name { + println!( + "\r\n[Created and switching to new session '{}' ({})]", + n, new_session.id + ); + } else { + println!( + "\r\n[Created and switching to new session {}]", + new_session.id + ); + } + Ok(SwitchResult::SwitchTo(new_session.id)) + } + Err(e) => { + eprintln!("\r\nError creating session: {}\r", e); + Ok(SwitchResult::Continue) + } + } + } + + /// Read user input with temporary cooked mode + fn read_user_input(&self) -> Result { + let stdin_borrowed = unsafe { BorrowedFd::borrow_raw(self.stdin_fd) }; + + // Save current raw mode settings + let current_termios = tcgetattr(&stdin_borrowed)?; + + // Restore to original (cooked) mode for line input + tcsetattr(&stdin_borrowed, SetArg::TCSANOW, self.original_termios)?; + + // Read user input + let stdin = io::stdin(); + let mut buffer = String::new(); + let read_result = stdin.lock().read_line(&mut buffer); + + // Restore raw mode + tcsetattr(&stdin_borrowed, SetArg::TCSANOW, ¤t_termios)?; + + read_result.map_err(|e| NdsError::Io(e))?; + Ok(buffer) + } +} + +/// Show the session help message +pub fn show_session_help() { + println!("\r\n[Session Commands]\r"); + println!("\r ~d - Detach from current session\r"); + println!("\r ~s - Switch sessions\r"); + println!("\r ~h - Show scrollback history\r"); + println!("\r ~~ - Send literal tilde\r"); + println!("\r\n[Press any key to continue]\r"); +} \ No newline at end of file diff --git a/src/pty/socket.rs b/src/pty/socket.rs new file mode 100644 index 0000000..a3caf77 --- /dev/null +++ b/src/pty/socket.rs @@ -0,0 +1,60 @@ +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::io::{self, Write}; + +use crate::error::{NdsError, Result}; +use crate::session::Session; + +/// Creates a Unix socket listener for a session +pub fn create_listener(session_id: &str) -> Result<(UnixListener, PathBuf)> { + let socket_path = Session::socket_dir()?.join(format!("{}.sock", session_id)); + + // Remove socket if it exists + if socket_path.exists() { + std::fs::remove_file(&socket_path)?; + } + + let listener = UnixListener::bind(&socket_path) + .map_err(|e| NdsError::SocketError(format!("Failed to bind socket: {}", e)))?; + + Ok((listener, socket_path)) +} + +/// Send a resize command to the daemon through the socket +pub fn send_resize_command(socket: &mut UnixStream, cols: u16, rows: u16) -> io::Result<()> { + // Format: \x1b]nds:resize::\x07 + let resize_cmd = format!("\x1b]nds:resize:{}:{}\x07", cols, rows); + socket.write_all(resize_cmd.as_bytes())?; + socket.flush() +} + +/// Parse NDS commands from socket data +/// Returns Some((command, args)) if a command is found, None otherwise +pub fn parse_nds_command(data: &[u8]) -> Option<(String, Vec)> { + // Check for special NDS commands + // Format: \x1b]nds:::...\x07 + if data.len() > 10 && data.starts_with(b"\x1b]nds:") { + if let Ok(cmd_str) = std::str::from_utf8(data) { + if let Some(end_idx) = cmd_str.find('\x07') { + let cmd = &cmd_str[6..end_idx]; // Skip \x1b]nds: + let parts: Vec = cmd.split(':').map(String::from).collect(); + if !parts.is_empty() { + return Some((parts[0].clone(), parts[1..].to_vec())); + } + } + } + } + None +} + +/// Get the end index of an NDS command in the data +pub fn get_command_end(data: &[u8]) -> Option { + if data.starts_with(b"\x1b]nds:") { + if let Ok(cmd_str) = std::str::from_utf8(data) { + if let Some(end_idx) = cmd_str.find('\x07') { + return Some(end_idx + 1); + } + } + } + None +} \ No newline at end of file diff --git a/src/pty/spawn.rs b/src/pty/spawn.rs new file mode 100644 index 0000000..6be18b4 --- /dev/null +++ b/src/pty/spawn.rs @@ -0,0 +1,866 @@ +use std::io::{self, Read, Write}; +use std::os::unix::io::RawFd; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::terminal; +use nix::fcntl::{fcntl, FcntlArg, OFlag}; +use nix::sys::signal::{kill, Signal}; +use nix::sys::termios::Termios; +use nix::unistd::{close, dup2, execvp, fork, setsid, ForkResult, Pid}; + +use crate::error::{NdsError, Result}; +use crate::pty_buffer::PtyBuffer; +use crate::scrollback::ScrollbackViewer; +use crate::session::Session; +use super::client::ClientInfo; +use super::io_handler::{PtyIoHandler, ScrollbackHandler, spawn_socket_to_stdout_thread, spawn_resize_monitor_thread, send_buffered_output}; +use super::session_switcher::{SessionSwitcher, SwitchResult}; +use super::socket::{create_listener, send_resize_command, parse_nds_command, get_command_end}; +use super::terminal::{ + save_terminal_state, set_raw_mode, restore_terminal, get_terminal_size, + set_terminal_size, set_stdin_nonblocking, send_refresh, send_terminal_refresh_sequences, + capture_terminal_state +}; + +pub struct PtyProcess { + pub master_fd: RawFd, + pub pid: Pid, + pub socket_path: PathBuf, + listener: Option, + output_buffer: Option, +} + +impl PtyProcess { + /// Open a new PTY pair (master and slave) + fn open_pty() -> Result<(RawFd, RawFd)> { + unsafe { + // Open PTY master + let master_fd = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY); + if master_fd < 0 { + return Err(NdsError::PtyError("Failed to open PTY master".to_string())); + } + + // Grant access to slave + if libc::grantpt(master_fd) < 0 { + let _ = libc::close(master_fd); + return Err(NdsError::PtyError("Failed to grant PTY access".to_string())); + } + + // Unlock slave + if libc::unlockpt(master_fd) < 0 { + let _ = libc::close(master_fd); + return Err(NdsError::PtyError("Failed to unlock PTY".to_string())); + } + + // Get slave name + let slave_name = libc::ptsname(master_fd); + if slave_name.is_null() { + let _ = libc::close(master_fd); + return Err(NdsError::PtyError( + "Failed to get PTY slave name".to_string(), + )); + } + + // Open slave + let slave_cstr = std::ffi::CStr::from_ptr(slave_name); + let slave_fd = libc::open(slave_cstr.as_ptr(), libc::O_RDWR); + if slave_fd < 0 { + let _ = libc::close(master_fd); + return Err(NdsError::PtyError("Failed to open PTY slave".to_string())); + } + + Ok((master_fd, slave_fd)) + } + } + + /// Spawn a new detached session + pub fn spawn_new_detached(session_id: &str) -> Result { + Self::spawn_new_detached_with_name(session_id, None) + } + + /// Spawn a new detached session with a custom name + pub fn spawn_new_detached_with_name(session_id: &str, name: Option) -> Result { + // Capture terminal size BEFORE detaching + let (cols, rows) = terminal::size().unwrap_or((80, 24)); + + // First fork to create intermediate process + match unsafe { fork() } + .map_err(|e| NdsError::ForkError(format!("First fork failed: {}", e)))? + { + ForkResult::Parent { child: _ } => { + // Wait for the intermediate process to complete + thread::sleep(Duration::from_millis(200)); + + // Load the session that was created by the daemon + Session::load(session_id) + } + ForkResult::Child => { + // We're in the intermediate process + // Create a new session to detach from the terminal + setsid().map_err(|e| NdsError::ProcessError(format!("setsid failed: {}", e)))?; + + // Second fork to ensure we can't acquire a controlling terminal + match unsafe { fork() } + .map_err(|e| NdsError::ForkError(format!("Second fork failed: {}", e)))? + { + ForkResult::Parent { child: _ } => { + // Intermediate process exits immediately + std::process::exit(0); + } + ForkResult::Child => { + // We're now in the daemon process + // Close standard file descriptors to fully detach + unsafe { + libc::close(0); + libc::close(1); + libc::close(2); + + // Redirect to /dev/null + let dev_null = libc::open( + b"/dev/null\0".as_ptr() as *const libc::c_char, + libc::O_RDWR, + ); + if dev_null >= 0 { + libc::dup2(dev_null, 0); + libc::dup2(dev_null, 1); + libc::dup2(dev_null, 2); + if dev_null > 2 { + libc::close(dev_null); + } + } + } + + // Continue with PTY setup, passing the captured terminal size + let (pty_process, _session) = + Self::spawn_new_internal_with_size(session_id, name, cols, rows)?; + + // Run the PTY handler + if let Err(_e) = pty_process.run_detached() { + // Can't print errors anymore since stdout is closed + } + + // Clean up when done + Session::cleanup(session_id).ok(); + std::process::exit(0); + } + } + } + } + } + + fn spawn_new_internal_with_size( + session_id: &str, + name: Option, + cols: u16, + rows: u16, + ) -> Result<(Self, Session)> { + // Open PTY + let (master_fd, slave_fd) = Self::open_pty()?; + + // Set terminal size on slave + set_terminal_size(slave_fd, cols, rows)?; + + // Set non-blocking on master + let flags = fcntl(master_fd, FcntlArg::F_GETFL) + .map_err(|e| NdsError::PtyError(format!("Failed to get flags: {}", e)))?; + fcntl( + master_fd, + FcntlArg::F_SETFL(OFlag::from_bits_truncate(flags) | OFlag::O_NONBLOCK), + ) + .map_err(|e| NdsError::PtyError(format!("Failed to set non-blocking: {}", e)))?; + + // Create socket for IPC + let (listener, socket_path) = create_listener(session_id)?; + + // Fork process + match unsafe { fork() }.map_err(|e| NdsError::ForkError(e.to_string()))? { + ForkResult::Parent { child } => { + // Close slave in parent + let _ = close(slave_fd); + + // Create session metadata + let session = Session::with_name( + session_id.to_string(), + name, + child.as_raw(), + socket_path.clone(), + ); + session.save().map_err(|e| { + eprintln!("Failed to save session: {}", e); + e + })?; + + let pty_process = PtyProcess { + master_fd, + pid: child, + socket_path, + listener: Some(listener), + output_buffer: Some(PtyBuffer::new(1024 * 1024)), // 1MB buffer + }; + + Ok((pty_process, session)) + } + ForkResult::Child => { + // Close master in child + let _ = close(master_fd); + + // Create new session + setsid().map_err(|e| NdsError::ProcessError(format!("setsid failed: {}", e)))?; + + // Make slave the controlling terminal + unsafe { + if libc::ioctl(slave_fd, libc::TIOCSCTTY as u64, 0) < 0 { + eprintln!("Failed to set controlling terminal"); + std::process::exit(1); + } + } + + // Duplicate slave to stdin/stdout/stderr + dup2(slave_fd, 0) + .map_err(|e| NdsError::ProcessError(format!("dup2 stdin failed: {}", e)))?; + dup2(slave_fd, 1) + .map_err(|e| NdsError::ProcessError(format!("dup2 stdout failed: {}", e)))?; + dup2(slave_fd, 2) + .map_err(|e| NdsError::ProcessError(format!("dup2 stderr failed: {}", e)))?; + + // Close original slave + if slave_fd > 2 { + let _ = close(slave_fd); + } + + // Set environment variables for session tracking + std::env::set_var("NDS_SESSION_ID", session_id); + if let Some(ref session_name) = name { + std::env::set_var("NDS_SESSION_NAME", session_name); + } else { + std::env::set_var("NDS_SESSION_NAME", session_id); + } + + // Get shell + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + + // Execute shell + let shell_cstr = std::ffi::CString::new(shell.as_str()).unwrap(); + let args = vec![shell_cstr.clone()]; + + execvp(&shell_cstr, &args) + .map_err(|e| NdsError::ProcessError(format!("execvp failed: {}", e)))?; + + // Should never reach here + unreachable!() + } + } + } + + /// Attach to an existing session + pub fn attach_to_session(session: &Session) -> Result> { + // Set environment variables + std::env::set_var("NDS_SESSION_ID", &session.id); + std::env::set_var("NDS_SESSION_NAME", session.name.as_ref().unwrap_or(&session.id)); + + // Save current terminal state + let stdin_fd = 0; + let original_termios = save_terminal_state(stdin_fd)?; + + // Capture current terminal state for restoration + let _terminal_state = capture_terminal_state(stdin_fd)?; + + // Connect to session socket + let mut socket = session.connect_socket()?; + + // Get current terminal size and send resize command + let (cols, rows) = get_terminal_size()?; + send_resize_command(&mut socket, cols, rows)?; + thread::sleep(Duration::from_millis(50)); + + // Send refresh to restore terminal state + send_refresh(&mut socket)?; + thread::sleep(Duration::from_millis(50)); + + // Set terminal to raw mode + set_raw_mode(stdin_fd, &original_termios)?; + + // Create a flag for clean shutdown + let running = Arc::new(AtomicBool::new(true)); + let r1 = running.clone(); + let r2 = running.clone(); + + // Handle Ctrl+C + ctrlc::set_handler(move || { + r1.store(false, Ordering::SeqCst); + }) + .map_err(|e| NdsError::SignalError(format!("Failed to set signal handler: {}", e)))?; + + println!("\r\n[Attached to session {}]\r", session.id); + println!("[Press Enter then ~d to detach, ~s to switch, ~h for history]\r"); + + // Create scrollback handler + let scrollback = ScrollbackHandler::new(10 * 1024 * 1024); // 10MB + + // Spawn resize monitor thread + let socket_for_resize = socket.try_clone() + .map_err(|e| NdsError::SocketError(format!("Failed to clone socket: {}", e)))?; + let resize_running = running.clone(); + let _resize_monitor = spawn_resize_monitor_thread(socket_for_resize, resize_running, (cols, rows)); + + // Spawn socket to stdout thread + let socket_clone = socket.try_clone() + .map_err(|e| NdsError::SocketError(format!("Failed to clone socket: {}", e)))?; + let socket_to_stdout = spawn_socket_to_stdout_thread( + socket_clone, + r2, + scrollback.get_shared_buffer() + ); + + // Set stdin to non-blocking + set_stdin_nonblocking(stdin_fd)?; + + // Main input loop + let result = Self::handle_input_loop( + &mut socket, + session, + &original_termios, + &running, + &scrollback, + ); + + // Clean up + running.store(false, Ordering::SeqCst); + let _ = socket.shutdown(std::net::Shutdown::Both); + drop(socket); + thread::sleep(Duration::from_millis(50)); + let _ = socket_to_stdout.join(); + + // Restore terminal + restore_terminal(stdin_fd, &original_termios)?; + + // Clear environment variables + std::env::remove_var("NDS_SESSION_ID"); + std::env::remove_var("NDS_SESSION_NAME"); + + println!("\n[Detached from session {}]", session.id); + let _ = io::stdout().flush(); + + result + } + + fn handle_input_loop( + socket: &mut UnixStream, + session: &Session, + original_termios: &Termios, + running: &Arc, + scrollback: &ScrollbackHandler, + ) -> Result> { + let stdin_fd = 0i32; + let mut stdin = io::stdin(); + let mut buffer = [0u8; 1024]; + + // SSH-style escape sequence tracking + let mut at_line_start = true; + let mut escape_state = 0; // 0=normal, 1=saw tilde at line start + let mut escape_time = Instant::now(); + + loop { + if !running.load(Ordering::SeqCst) { + break; + } + + match stdin.read(&mut buffer) { + Ok(0) => { + // EOF (Ctrl+D) - treat as detach + println!("\r\n[Detaching from session {}]\r", session.id); + running.store(false, Ordering::SeqCst); + break; + } + Ok(n) => { + let (should_detach, should_switch, should_scroll, data_to_forward) = + Self::process_input(&buffer[..n], &mut at_line_start, &mut escape_state, &mut escape_time); + + if should_detach { + println!("\r\n[Detaching from session {}]\r", session.id); + running.store(false, Ordering::SeqCst); + break; + } + + if should_switch { + let switcher = SessionSwitcher::new(session, stdin_fd, original_termios); + match switcher.show_switcher()? { + SwitchResult::SwitchTo(target_id) => { + return Ok(Some(target_id)); + } + SwitchResult::Continue => { + escape_state = 0; + at_line_start = true; + } + } + } + + if should_scroll { + Self::show_scrollback_viewer(original_termios, socket, scrollback)?; + escape_state = 0; + at_line_start = true; + } + + // Forward the processed data + if !data_to_forward.is_empty() { + if let Err(e) = socket.write_all(&data_to_forward) { + if e.kind() == io::ErrorKind::BrokenPipe { + break; + } else { + eprintln!("\r\nError writing to socket: {}\r", e); + break; + } + } + } + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + if !running.load(Ordering::SeqCst) { + break; + } + thread::sleep(Duration::from_millis(10)); + } + Err(e) => { + eprintln!("\r\nError reading stdin: {}\r", e); + break; + } + } + } + + Ok(None) + } + + fn process_input( + buffer: &[u8], + at_line_start: &mut bool, + escape_state: &mut u8, + escape_time: &mut Instant, + ) -> (bool, bool, bool, Vec) { + let mut should_detach = false; + let mut should_switch = false; + let mut should_scroll = false; + let mut data_to_forward = Vec::new(); + + // Check for escape timeout (reset after 1 second) + if *escape_state == 1 && escape_time.elapsed() > Duration::from_secs(1) { + // Timeout - forward the held tilde and reset + data_to_forward.push(b'~'); + *escape_state = 0; + } + + // Process each byte for escape sequence + for &byte in buffer { + // Check for Ctrl+D (ASCII 4) - detach this client only + if byte == 0x04 { + should_detach = true; + break; + } + + match *escape_state { + 0 => { + // Normal state + if *at_line_start && byte == b'~' { + // Start of potential escape sequence + *escape_state = 1; + *escape_time = Instant::now(); + // Don't forward the tilde yet + } else { + // Regular character + data_to_forward.push(byte); + // Update line start tracking + *at_line_start = byte == b'\r' || byte == b'\n' || byte == 10 || byte == 13; + } + } + 1 => { + // We saw ~ at the beginning of a line + match byte { + b'd' => { + should_detach = true; + break; + } + b's' => { + should_switch = true; + break; + } + b'h' => { + should_scroll = true; + break; + } + b'~' => { + // ~~ means literal tilde + data_to_forward.push(b'~'); + *escape_state = 0; + *at_line_start = false; + } + _ => { + // Not an escape sequence, forward tilde and this char + data_to_forward.push(b'~'); + data_to_forward.push(byte); + *escape_state = 0; + *at_line_start = byte == b'\r' || byte == b'\n' || byte == 10 || byte == 13; + } + } + } + _ => { + *escape_state = 0; + } + } + } + + (should_detach, should_switch, should_scroll, data_to_forward) + } + + fn show_scrollback_viewer( + original_termios: &Termios, + socket: &mut UnixStream, + scrollback: &ScrollbackHandler, + ) -> Result<()> { + use nix::sys::termios::{tcsetattr, SetArg}; + use std::os::unix::io::BorrowedFd; + + println!("\r\n[Opening scrollback viewer...]\r"); + + // Get scrollback content + let content = scrollback.get_buffer(); + + // Temporarily restore terminal for viewer + let stdin_fd = 0; + let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; + + // Get current raw mode settings + let raw_termios = nix::sys::termios::tcgetattr(&stdin)?; + + // Restore to original mode for viewer + tcsetattr(&stdin, SetArg::TCSANOW, original_termios)?; + + // Show scrollback viewer + let mut viewer = ScrollbackViewer::new(&content); + let _ = viewer.run(); // Ignore errors, just return to session + + // Re-enter raw mode + tcsetattr(&stdin, SetArg::TCSANOW, &raw_termios)?; + + // Refresh display + send_refresh(socket)?; + println!("\r\n[Returned to session]\r"); + + Ok(()) + } + + /// Run the detached PTY handler + pub fn run_detached(mut self) -> Result<()> { + let listener = self.listener.take() + .ok_or_else(|| NdsError::PtyError("No listener available".to_string()))?; + + // Set listener to non-blocking + listener.set_nonblocking(true)?; + + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); + + // Handle cleanup on exit + ctrlc::set_handler(move || { + r.store(false, Ordering::SeqCst); + }) + .map_err(|e| NdsError::SignalError(format!("Failed to set signal handler: {}", e)))?; + + let output_buffer = self.output_buffer.take() + .ok_or_else(|| NdsError::PtyError("No output buffer available".to_string()))?; + + // Support multiple concurrent clients + let mut active_clients: Vec = Vec::new(); + let mut buffer = [0u8; 4096]; + + // Get session ID from socket path + let session_id = self.socket_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Create IO handler + let io_handler = PtyIoHandler::new(self.master_fd); + + while running.load(Ordering::SeqCst) { + // Check for new connections + self.handle_new_connections( + &listener, + &mut active_clients, + &output_buffer, + &io_handler, + &session_id, + )?; + + // Read from PTY master and broadcast + if let Some(data) = self.read_from_pty(&io_handler, &mut buffer)? { + self.broadcast_to_clients(&mut active_clients, &data, &output_buffer, &session_id)?; + } + + // Read from clients and handle input + self.handle_client_input(&mut active_clients, &io_handler, &session_id)?; + + // Small sleep to prevent busy loop + thread::sleep(Duration::from_millis(10)); + } + + Ok(()) + } + + fn handle_new_connections( + &self, + listener: &UnixListener, + active_clients: &mut Vec, + output_buffer: &PtyBuffer, + io_handler: &PtyIoHandler, + session_id: &str, + ) -> Result<()> { + match listener.accept() { + Ok((mut stream, _)) => { + stream.set_nonblocking(true)?; + + // Notify existing clients + if !active_clients.is_empty() { + let notification = format!( + "\r\n[Another client connected to this session (total: {})]\r\n", + active_clients.len() + 1 + ); + for client in active_clients.iter_mut() { + let _ = client.stream.write_all(notification.as_bytes()); + let _ = client.stream.flush(); + } + } + + // Send buffered output to new client + send_buffered_output(&mut stream, output_buffer, io_handler)?; + + // Add new client + active_clients.push(ClientInfo { + stream, + cols: 80, + rows: 24, + }); + + // Update client count in status file + let _ = Session::update_client_count(session_id, active_clients.len()); + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + // No new connections + } + Err(_) => { + // Error accepting connection, continue + } + } + Ok(()) + } + + fn read_from_pty(&self, io_handler: &PtyIoHandler, buffer: &mut [u8]) -> Result>> { + match io_handler.read_from_pty(buffer) { + Ok(0) => Err(NdsError::PtyError("Child process exited".to_string())), + Ok(n) => Ok(Some(buffer[..n].to_vec())), + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None), + Err(e) => Err(NdsError::Io(e)), + } + } + + fn broadcast_to_clients( + &self, + active_clients: &mut Vec, + data: &[u8], + output_buffer: &PtyBuffer, + session_id: &str, + ) -> Result<()> { + if !active_clients.is_empty() { + let mut disconnected_indices = Vec::new(); + + for (i, client) in active_clients.iter_mut().enumerate() { + if let Err(e) = client.stream.write_all(data) { + if e.kind() == io::ErrorKind::BrokenPipe + || e.kind() == io::ErrorKind::ConnectionAborted + { + disconnected_indices.push(i); + } + } else { + let _ = client.stream.flush(); + } + } + + // Remove disconnected clients + if !disconnected_indices.is_empty() { + self.handle_client_disconnections( + active_clients, + disconnected_indices, + session_id, + )?; + } + + // Buffer if no clients + if active_clients.is_empty() { + output_buffer.push(data); + } + } else { + // No clients connected, buffer the output + output_buffer.push(data); + } + Ok(()) + } + + fn handle_client_disconnections( + &self, + active_clients: &mut Vec, + disconnected_indices: Vec, + session_id: &str, + ) -> Result<()> { + + for i in disconnected_indices.iter().rev() { + active_clients.remove(*i); + } + + // Update client count + let _ = Session::update_client_count(session_id, active_clients.len()); + + // Notify remaining clients and resize + if !active_clients.is_empty() { + let notification = format!( + "\r\n[A client disconnected (remaining: {})]\r\n", + active_clients.len() + ); + + for client in active_clients.iter_mut() { + let _ = client.stream.write_all(notification.as_bytes()); + let _ = send_terminal_refresh_sequences(&mut client.stream); + let _ = client.stream.flush(); + } + + // Resize to smallest terminal + self.resize_to_smallest(active_clients)?; + } + Ok(()) + } + + fn resize_to_smallest(&self, active_clients: &[ClientInfo]) -> Result<()> { + let mut min_cols = u16::MAX; + let mut min_rows = u16::MAX; + + for client in active_clients { + min_cols = min_cols.min(client.cols); + min_rows = min_rows.min(client.rows); + } + + if min_cols != u16::MAX && min_rows != u16::MAX { + set_terminal_size(self.master_fd, min_cols, min_rows)?; + let _ = kill(self.pid, Signal::SIGWINCH); + + // Send refresh + let io_handler = PtyIoHandler::new(self.master_fd); + let _ = io_handler.send_refresh(); + } + Ok(()) + } + + fn handle_client_input( + &self, + active_clients: &mut Vec, + io_handler: &PtyIoHandler, + session_id: &str, + ) -> Result<()> { + let mut disconnected_indices = Vec::new(); + let mut client_buffer = [0u8; 1024]; + + for (i, client) in active_clients.iter_mut().enumerate() { + match client.stream.read(&mut client_buffer) { + Ok(0) => { + disconnected_indices.push(i); + } + Ok(n) => { + let data = &client_buffer[..n]; + + // Check for NDS commands + if let Some((cmd, args)) = parse_nds_command(data) { + if cmd == "resize" && args.len() == 2 { + if let (Ok(cols), Ok(rows)) = (args[0].parse::(), args[1].parse::()) { + client.cols = cols; + client.rows = rows; + set_terminal_size(self.master_fd, cols, rows)?; + let _ = kill(self.pid, Signal::SIGWINCH); + + // Forward any remaining data after command + if let Some(end_idx) = get_command_end(data) { + if end_idx < n { + io_handler.write_to_pty(&data[end_idx..])?; + } + } + continue; + } + } + } + + // Normal data - forward to PTY + io_handler.write_to_pty(data)?; + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + // No data available + } + Err(_) => { + disconnected_indices.push(i); + } + } + } + + // Handle disconnections + if !disconnected_indices.is_empty() { + self.handle_client_disconnections(active_clients, disconnected_indices, session_id)?; + } + + Ok(()) + } + + /// Kill a session by its ID + pub fn kill_session(session_id: &str) -> Result<()> { + let session = Session::load(session_id)?; + + // Send SIGTERM to the process + kill(Pid::from_raw(session.pid), Signal::SIGTERM) + .map_err(|e| NdsError::ProcessError(format!("Failed to kill process: {}", e)))?; + + // Wait a moment for graceful shutdown + thread::sleep(Duration::from_millis(500)); + + // Force kill if still alive + if Session::is_process_alive(session.pid) { + kill(Pid::from_raw(session.pid), Signal::SIGKILL).map_err(|e| { + NdsError::ProcessError(format!("Failed to force kill process: {}", e)) + })?; + } + + // Clean up session files + Session::cleanup(session_id)?; + + Ok(()) + } +} + +impl Drop for PtyProcess { + fn drop(&mut self) { + let _ = close(self.master_fd); + if let Some(listener) = self.listener.take() { + drop(listener); + } + } +} + +// Public convenience functions for backward compatibility +pub fn spawn_new_detached(session_id: &str) -> Result { + PtyProcess::spawn_new_detached(session_id) +} + +pub fn spawn_new_detached_with_name(session_id: &str, name: Option) -> Result { + PtyProcess::spawn_new_detached_with_name(session_id, name) +} + +pub fn kill_session(session_id: &str) -> Result<()> { + PtyProcess::kill_session(session_id) +} \ No newline at end of file diff --git a/src/pty/terminal.rs b/src/pty/terminal.rs new file mode 100644 index 0000000..0321ced --- /dev/null +++ b/src/pty/terminal.rs @@ -0,0 +1,127 @@ +use std::os::unix::io::{BorrowedFd, RawFd}; +use std::thread; +use std::time::Duration; +use std::io::{self, Write}; + +use crossterm::terminal; +use nix::sys::termios::{tcflush, tcgetattr, tcsetattr, FlushArg, SetArg, Termios}; +use nix::sys::termios::{InputFlags, OutputFlags, ControlFlags, LocalFlags, SpecialCharacterIndices}; + +use crate::error::{NdsError, Result}; +use crate::terminal_state::TerminalState; + +/// Save the current terminal state +pub fn save_terminal_state(stdin_fd: RawFd) -> Result { + let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; + tcgetattr(&stdin).map_err(|e| { + NdsError::TerminalError(format!("Failed to get terminal attributes: {}", e)) + }) +} + +/// Set terminal to raw mode +pub fn set_raw_mode(stdin_fd: RawFd, original: &Termios) -> Result<()> { + let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; + + let mut raw = original.clone(); + // Manually set raw mode flags + raw.input_flags = InputFlags::empty(); + raw.output_flags = OutputFlags::empty(); + raw.control_flags |= ControlFlags::CS8; + raw.local_flags = LocalFlags::empty(); + raw.control_chars[SpecialCharacterIndices::VMIN as usize] = 1; + raw.control_chars[SpecialCharacterIndices::VTIME as usize] = 0; + + tcsetattr(&stdin, SetArg::TCSANOW, &raw) + .map_err(|e| NdsError::TerminalError(format!("Failed to set raw mode: {}", e))) +} + +/// Restore terminal to original state +pub fn restore_terminal(stdin_fd: RawFd, original: &Termios) -> Result<()> { + let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; + + // First restore stdin to blocking mode + unsafe { + let flags = libc::fcntl(stdin_fd, libc::F_GETFL); + libc::fcntl(stdin_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); + } + + // Clear any pending input from stdin buffer + tcflush(&stdin, FlushArg::TCIFLUSH) + .map_err(|e| NdsError::TerminalError(format!("Failed to flush stdin: {}", e)))?; + + // Restore the terminal settings + tcsetattr(&stdin, SetArg::TCSANOW, original) + .map_err(|e| NdsError::TerminalError(format!("Failed to restore terminal: {}", e)))?; + + // Ensure we're back in cooked mode + terminal::disable_raw_mode().ok(); + + // Clear any remaining input after terminal restore + tcflush(&stdin, FlushArg::TCIFLUSH) + .map_err(|e| NdsError::TerminalError(format!("Failed to flush stdin after restore: {}", e)))?; + + // Add a small delay to ensure terminal is fully restored + thread::sleep(Duration::from_millis(50)); + + Ok(()) +} + +/// Get current terminal size +pub fn get_terminal_size() -> Result<(u16, u16)> { + terminal::size().map_err(|e| NdsError::TerminalError(e.to_string())) +} + +/// Set terminal size on a file descriptor +pub fn set_terminal_size(fd: RawFd, cols: u16, rows: u16) -> Result<()> { + unsafe { + let winsize = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + if libc::ioctl(fd, libc::TIOCSWINSZ as u64, &winsize) < 0 { + return Err(NdsError::PtyError("Failed to set terminal size".to_string())); + } + } + Ok(()) +} + +/// Set stdin to non-blocking mode +pub fn set_stdin_nonblocking(stdin_fd: RawFd) -> Result<()> { + unsafe { + let flags = libc::fcntl(stdin_fd, libc::F_GETFL); + if libc::fcntl(stdin_fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { + return Err(NdsError::TerminalError("Failed to set stdin non-blocking".to_string())); + } + } + Ok(()) +} + +/// Send a refresh command to the terminal +pub fn send_refresh(stream: &mut impl Write) -> io::Result<()> { + // Send Ctrl+L to refresh the display + stream.write_all(b"\x0c")?; + stream.flush() +} + +/// Send terminal refresh sequences to restore normal state +pub fn send_terminal_refresh_sequences(stream: &mut impl Write) -> io::Result<()> { + let refresh_sequences = [ + "\x1b[?25h", // Show cursor + "\x1b[?12h", // Enable cursor blinking + "\x1b[1 q", // Blinking block cursor (default) + "\x1b[m", // Reset all attributes + "\x1b[?1000l", // Disable mouse tracking (if enabled) + "\x1b[?1002l", // Disable cell motion mouse tracking + "\x1b[?1003l", // Disable all motion mouse tracking + ].join(""); + + stream.write_all(refresh_sequences.as_bytes())?; + stream.flush() +} + +/// Capture current terminal state for restoration +pub fn capture_terminal_state(stdin_fd: RawFd) -> Result { + TerminalState::capture(stdin_fd) +} \ No newline at end of file diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 231cfbe..8051c3e 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -8,7 +8,7 @@ fn test_cli_version() { cmd.arg("--version") .assert() .success() - .stdout(predicate::str::contains("nds 0.1.0")); + .stdout(predicate::str::contains("nds 0.1.1")); } #[test] From c488f3565424d3418a01cc9aacbb31f46b3804d6 Mon Sep 17 00:00:00 2001 From: Kerem Noras Date: Sat, 13 Sep 2025 12:32:32 +0300 Subject: [PATCH 3/4] refactor: Extract main.rs handlers into organized modules Reduced main.rs from 621 to 125 lines by extracting handlers: - handlers/session.rs (229 lines) - Session management operations - handle_new_session, handle_attach_session, handle_kill_sessions - handle_rename_session, handle_clean_sessions - handlers/info.rs (313 lines) - Information display operations - handle_list_sessions, handle_session_info, handle_session_history Benefits: - 80% reduction in main.rs file size - Clear separation of concerns - Better code organization - Easier testing and maintenance --- src/handlers/info.rs | 314 ++++++++++++++++++++++++ src/handlers/mod.rs | 18 ++ src/handlers/session.rs | 230 ++++++++++++++++++ src/main.rs | 522 +--------------------------------------- 4 files changed, 575 insertions(+), 509 deletions(-) create mode 100644 src/handlers/info.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/handlers/session.rs diff --git a/src/handlers/info.rs b/src/handlers/info.rs new file mode 100644 index 0000000..a15cb6b --- /dev/null +++ b/src/handlers/info.rs @@ -0,0 +1,314 @@ +use chrono::{DateTime, Local}; +use detached_shell::{ + NdsError, Result, Session, SessionEvent, SessionHistory, SessionManager, SessionTable, +}; +use std::collections::HashSet; + +/// Lists all active sessions with optional interactive mode +pub fn handle_list_sessions(interactive: bool) -> Result<()> { + if interactive { + // Interactive mode - let user select and attach + use detached_shell::interactive::InteractivePicker; + + match InteractivePicker::new() { + Ok(mut picker) => { + match picker.run()? { + Some(session_id) => { + // User selected a session, attach to it + println!("Attaching to session {}...", session_id); + crate::handlers::session::handle_attach_session(&session_id)?; + } + None => { + // User quit without selecting + println!("No session selected."); + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + if matches!(e, NdsError::SessionNotFound(_)) { + println!("No active sessions found."); + } + } + } + } else { + // Normal list mode + let sessions = SessionManager::list_sessions()?; + let table = SessionTable::new(sessions); + table.print(); + } + Ok(()) +} + +/// Shows detailed information about a specific session +pub fn handle_session_info(session_id_or_name: &str) -> Result<()> { + // Allow partial ID or name matching + let sessions = SessionManager::list_sessions()?; + + // First try to match by ID + let mut matching_sessions: Vec<_> = sessions + .iter() + .filter(|s| s.id.starts_with(session_id_or_name)) + .collect(); + + // If no ID matches, try matching by name + if matching_sessions.is_empty() { + matching_sessions = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name == session_id_or_name || + name.starts_with(session_id_or_name) || + name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + } + + match matching_sessions.len() { + 0 => { + eprintln!("No session found matching ID or name: {}", session_id_or_name); + Err(NdsError::SessionNotFound(session_id_or_name.to_string())) + } + 1 => { + let session = matching_sessions[0]; + let client_count = session.get_client_count(); + + println!("Session ID: {}", session.id); + if let Some(ref name) = session.name { + println!("Session Name: {}", name); + } + println!("PID: {}", session.pid); + println!("Created: {}", session.created_at); + println!("Socket: {}", session.socket_path.display()); + println!("Shell: {}", session.shell); + println!("Working Directory: {}", session.working_dir); + println!( + "Status: {}", + if client_count > 0 { + format!("Attached ({} client(s))", client_count) + } else { + "Detached".to_string() + } + ); + Ok(()) + } + _ => { + eprintln!( + "Multiple sessions match '{}'. Please be more specific:", + session_id_or_name + ); + for session in matching_sessions { + eprintln!(" - {}", session.display_name()); + } + Err(NdsError::InvalidSessionId(session_id_or_name.to_string())) + } + } +} + +/// Shows session history with various filtering options +pub fn handle_session_history( + session_id_or_name: Option, + all: bool, + limit: usize, +) -> Result<()> { + // Migrate old format if needed + let _ = SessionHistory::migrate_from_single_file(); + + if let Some(ref id_or_name) = session_id_or_name { + // Show history for specific session + handle_specific_session_history(id_or_name, limit) + } else { + // Show all history or active sessions only + handle_general_session_history(all, limit) + } +} + +/// Helper function to handle history for a specific session +fn handle_specific_session_history(id_or_name: &str, limit: usize) -> Result<()> { + // First try to resolve session name to ID + let sessions = SessionManager::list_sessions()?; + let resolved_id = resolve_session_id(id_or_name, &sessions)?; + + // Show history for specific session + let entries = SessionHistory::get_session_history(&resolved_id)?; + + if entries.is_empty() { + println!("No history found for session: {}", resolved_id); + return Ok(()); + } + + println!("History for session {}:", resolved_id); + println!("{:-<80}", ""); + + for entry in entries.iter().take(limit) { + let local_time: DateTime = entry.timestamp.into(); + let time_str = local_time.format("%Y-%m-%d %H:%M:%S").to_string(); + + let event_str = format_session_event(&entry.event, entry.duration_seconds); + + println!( + "{} | {:<20} | PID: {} | {}", + time_str, event_str, entry.pid, entry.working_dir + ); + } + + Ok(()) +} + +/// Helper function to handle general session history +fn handle_general_session_history(all: bool, limit: usize) -> Result<()> { + let entries = SessionHistory::load_all_history(all, Some(limit))?; + + let filtered_entries: Vec<_> = if !all { + // Filter to show only entries for currently active sessions + let active_sessions = SessionManager::list_sessions()?; + let active_ids: HashSet<_> = active_sessions.iter().map(|s| s.id.clone()).collect(); + + entries + .into_iter() + .filter(|e| active_ids.contains(&e.session_id)) + .collect() + } else { + entries + }; + + if filtered_entries.is_empty() { + if all { + println!("No session history found."); + } else { + println!("No history for active sessions. Use --all to see all history."); + } + return Ok(()); + } + + print_history_table(&filtered_entries, all); + Ok(()) +} + +/// Helper function to resolve session name to ID +fn resolve_session_id(id_or_name: &str, sessions: &[Session]) -> Result { + if sessions.iter().any(|s| s.id == id_or_name) { + // It's already a session ID + return Ok(id_or_name.to_string()); + } + + // Try to find by name (case-insensitive partial matching) + let matches: Vec<&Session> = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name.to_lowercase().contains(&id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + + match matches.len() { + 0 => { + println!("No session found with ID or name matching: {}", id_or_name); + Err(NdsError::SessionNotFound(id_or_name.to_string())) + } + 1 => Ok(matches[0].id.clone()), + _ => { + println!( + "Multiple sessions match '{}'. Please be more specific:", + id_or_name + ); + for session in matches { + println!(" {} [{}]", session.display_name(), session.id); + } + Err(NdsError::InvalidSessionId(id_or_name.to_string())) + } + } +} + +/// Helper function to format session events +fn format_session_event(event: &SessionEvent, duration: Option) -> String { + match event { + SessionEvent::Created => "Created".to_string(), + SessionEvent::Attached => "Attached".to_string(), + SessionEvent::Detached => "Detached".to_string(), + SessionEvent::Killed => format!( + "Killed (duration: {})", + duration + .map(|d| SessionHistory::format_duration(d)) + .unwrap_or_else(|| "unknown".to_string()) + ), + SessionEvent::Crashed => format!( + "Crashed (duration: {})", + duration + .map(|d| SessionHistory::format_duration(d)) + .unwrap_or_else(|| "unknown".to_string()) + ), + SessionEvent::Renamed { from, to } => match from { + Some(old) => format!("Renamed from '{}' to '{}'", old, to), + None => format!("Named as '{}'", to), + }, + } +} + +/// Helper function to print history table +fn print_history_table(entries: &[detached_shell::HistoryEntry], all: bool) { + println!( + "{} Session History (showing {} entries)", + if all { "All" } else { "Active" }, + entries.len() + ); + println!("{:-<100}", ""); + println!( + "{:<20} {:<12} {:<20} {:<8} {:<10} {:<30}", + "Time", "Session", "Event", "PID", "Duration", "Working Dir" + ); + println!("{:-<100}", ""); + + for entry in entries { + let local_time: DateTime = entry.timestamp.into(); + let time_str = local_time.format("%Y-%m-%d %H:%M:%S").to_string(); + + let session_display = if let Some(ref name) = entry.session_name { + format!( + "{} [{}]", + name, + &entry.session_id[..8.min(entry.session_id.len())] + ) + } else { + entry.session_id[..8.min(entry.session_id.len())].to_string() + }; + + let (event_str, duration_str) = match &entry.event { + SessionEvent::Created => ("Created".to_string(), "-".to_string()), + SessionEvent::Attached => ("Attached".to_string(), "-".to_string()), + SessionEvent::Detached => ("Detached".to_string(), "-".to_string()), + SessionEvent::Killed => ( + "Killed".to_string(), + entry + .duration_seconds + .map(|d| SessionHistory::format_duration(d)) + .unwrap_or_else(|| "-".to_string()), + ), + SessionEvent::Crashed => ( + "Crashed".to_string(), + entry + .duration_seconds + .map(|d| SessionHistory::format_duration(d)) + .unwrap_or_else(|| "-".to_string()), + ), + SessionEvent::Renamed { .. } => ("Renamed".to_string(), "-".to_string()), + }; + + let working_dir = if entry.working_dir.len() > 30 { + format!("...{}", &entry.working_dir[entry.working_dir.len() - 27..]) + } else { + entry.working_dir.clone() + }; + + println!( + "{:<20} {:<12} {:<20} {:<8} {:<10} {:<30}", + time_str, session_display, event_str, entry.pid, duration_str, working_dir + ); + } +} \ No newline at end of file diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..6c3251e --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,18 @@ +// Module declarations +pub mod session; +pub mod info; + +// Re-export commonly used items for convenience +pub use session::{ + handle_attach_session, + handle_clean_sessions, + handle_kill_sessions, + handle_new_session, + handle_rename_session, +}; + +pub use info::{ + handle_list_sessions, + handle_session_history, + handle_session_info, +}; \ No newline at end of file diff --git a/src/handlers/session.rs b/src/handlers/session.rs new file mode 100644 index 0000000..33e278d --- /dev/null +++ b/src/handlers/session.rs @@ -0,0 +1,230 @@ +use detached_shell::{NdsError, Result, Session, SessionManager}; +use std::thread; +use std::time::Duration; + +/// Creates a new detached shell session with optional name +pub fn handle_new_session(name: Option, attach: bool) -> Result<()> { + if let Some(ref session_name) = name { + println!("Creating new session '{}'...", session_name); + } else { + println!("Creating new session..."); + } + + match SessionManager::create_session_with_name(name) { + Ok(session) => { + println!("Created session: {}", session.id); + println!("PID: {}", session.pid); + println!("Socket: {}", session.socket_path.display()); + + if attach { + println!("\nAttaching to session..."); + // Give the session a moment to fully initialize + thread::sleep(Duration::from_millis(100)); + handle_attach_session(&session.id)?; + } else { + println!("\nTo attach to this session, run:"); + println!(" nds attach {}", session.id); + } + Ok(()) + } + Err(e) => { + eprintln!("Failed to create session: {}", e); + Err(e) + } + } +} + +/// Attaches to an existing session by ID or name (supports partial matching) +pub fn handle_attach_session(session_id_or_name: &str) -> Result<()> { + // Allow partial ID or name matching + let sessions = SessionManager::list_sessions()?; + + // First try to match by ID + let mut matching_sessions: Vec<_> = sessions + .iter() + .filter(|s| s.id.starts_with(session_id_or_name)) + .collect(); + + // If no ID matches, try matching by name + if matching_sessions.is_empty() { + matching_sessions = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name == session_id_or_name || + name.starts_with(session_id_or_name) || + name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + } + + match matching_sessions.len() { + 0 => { + eprintln!("No session found matching ID or name: {}", session_id_or_name); + Err(NdsError::SessionNotFound(session_id_or_name.to_string())) + } + 1 => { + let session = matching_sessions[0]; + SessionManager::attach_session(&session.id)?; + Ok(()) + } + _ => { + eprintln!( + "Multiple sessions match '{}'. Please be more specific:", + session_id_or_name + ); + for session in matching_sessions { + eprintln!(" - {}", session.display_name()); + } + Err(NdsError::InvalidSessionId(session_id_or_name.to_string())) + } + } +} + +/// Kills one or more sessions by ID or name +pub fn handle_kill_sessions(session_ids: &[String]) -> Result<()> { + if session_ids.is_empty() { + eprintln!("No session IDs provided"); + return Err(NdsError::SessionNotFound( + "No session IDs provided".to_string(), + )); + } + + let sessions = SessionManager::list_sessions()?; + let mut killed_count = 0; + let mut errors = Vec::new(); + + for session_id in session_ids { + match kill_single_session(session_id, &sessions) { + Ok(killed_id) => { + println!("Killed session: {}", killed_id); + killed_count += 1; + } + Err(e) => { + eprintln!("Error killing session '{}': {}", session_id, e); + errors.push(format!("{}: {}", session_id, e)); + } + } + } + + if killed_count > 0 { + println!("Successfully killed {} session(s)", killed_count); + } + + if !errors.is_empty() && killed_count == 0 { + Err(NdsError::SessionNotFound(errors.join(", "))) + } else { + Ok(()) + } +} + +/// Helper function to kill a single session with partial matching support +fn kill_single_session(session_id_or_name: &str, sessions: &[Session]) -> Result { + // Allow partial ID or name matching + let mut matching_sessions: Vec<_> = sessions + .iter() + .filter(|s| s.id.starts_with(session_id_or_name)) + .collect(); + + // If no ID matches, try matching by name + if matching_sessions.is_empty() { + matching_sessions = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name == session_id_or_name || + name.starts_with(session_id_or_name) || + name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + } + + match matching_sessions.len() { + 0 => Err(NdsError::SessionNotFound(format!( + "No session found matching ID or name: {}", + session_id_or_name + ))), + 1 => { + let session = matching_sessions[0]; + SessionManager::kill_session(&session.id)?; + Ok(session.id.clone()) + } + _ => { + let matches: Vec = matching_sessions + .iter() + .map(|s| s.display_name()) + .collect(); + Err(NdsError::SessionNotFound(format!( + "Multiple sessions match '{}': {}. Please be more specific", + session_id_or_name, + matches.join(", ") + ))) + } + } +} + +/// Renames a session +pub fn handle_rename_session(session_id_or_name: &str, new_name: &str) -> Result<()> { + // Allow partial ID or name matching + let sessions = SessionManager::list_sessions()?; + + // First try to match by ID + let mut matching_sessions: Vec<_> = sessions + .iter() + .filter(|s| s.id.starts_with(session_id_or_name)) + .collect(); + + // If no ID matches, try matching by name + if matching_sessions.is_empty() { + matching_sessions = sessions + .iter() + .filter(|s| { + if let Some(ref name) = s.name { + name == session_id_or_name || + name.starts_with(session_id_or_name) || + name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + } else { + false + } + }) + .collect(); + } + + match matching_sessions.len() { + 0 => { + eprintln!("No session found matching ID or name: {}", session_id_or_name); + Err(NdsError::SessionNotFound(session_id_or_name.to_string())) + } + 1 => { + let session = matching_sessions[0]; + let old_display_name = session.display_name(); + SessionManager::rename_session(&session.id, new_name)?; + println!("Renamed session {} to '{}'", old_display_name, new_name); + Ok(()) + } + _ => { + eprintln!( + "Multiple sessions match '{}'. Please be more specific:", + session_id_or_name + ); + for session in matching_sessions { + eprintln!(" - {}", session.display_name()); + } + Err(NdsError::InvalidSessionId(session_id_or_name.to_string())) + } + } +} + +/// Cleans up dead sessions +pub fn handle_clean_sessions() -> Result<()> { + println!("Cleaning up dead sessions..."); + SessionManager::cleanup_dead_sessions()?; + println!("Cleanup complete."); + Ok(()) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 64a9efc..5390187 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ use clap::{Parser, Subcommand}; -use detached_shell::{ - NdsError, Result, Session, SessionEvent, SessionHistory, SessionManager, SessionTable, -}; +use detached_shell::Result; + +// Import handler modules +mod handlers; #[derive(Parser)] #[command(name = "nds")] @@ -86,536 +87,39 @@ fn main() -> Result<()> { match cli.command { Some(Commands::New { name, no_attach }) => { - handle_new_session(name, !no_attach)?; + handlers::handle_new_session(name, !no_attach)?; } Some(Commands::List { interactive }) => { - handle_list_sessions(interactive)?; + handlers::handle_list_sessions(interactive)?; } Some(Commands::Attach { id }) => { - handle_attach_session(&id)?; + handlers::handle_attach_session(&id)?; } Some(Commands::Kill { ids }) => { - handle_kill_sessions(&ids)?; + handlers::handle_kill_sessions(&ids)?; } Some(Commands::Info { id }) => { - handle_session_info(&id)?; + handlers::handle_session_info(&id)?; } Some(Commands::Rename { id, new_name }) => { - handle_rename_session(&id, &new_name)?; + handlers::handle_rename_session(&id, &new_name)?; } Some(Commands::Clean) => { - handle_clean_sessions()?; + handlers::handle_clean_sessions()?; } Some(Commands::History { session, all, limit, }) => { - handle_session_history(session, all, limit)?; + handlers::handle_session_history(session, all, limit)?; } None => { // Default action: interactive session picker - handle_list_sessions(true)?; - } - } - - Ok(()) -} - -fn handle_new_session(name: Option, attach: bool) -> Result<()> { - if let Some(ref session_name) = name { - println!("Creating new session '{}'...", session_name); - } else { - println!("Creating new session..."); - } - - match SessionManager::create_session_with_name(name) { - Ok(session) => { - println!("Created session: {}", session.id); - println!("PID: {}", session.pid); - println!("Socket: {}", session.socket_path.display()); - - if attach { - println!("\nAttaching to session..."); - // Give the session a moment to fully initialize - std::thread::sleep(std::time::Duration::from_millis(100)); - handle_attach_session(&session.id)?; - } else { - println!("\nTo attach to this session, run:"); - println!(" nds attach {}", session.id); - } - Ok(()) - } - Err(e) => { - eprintln!("Failed to create session: {}", e); - Err(e) - } - } -} - -fn handle_list_sessions(interactive: bool) -> Result<()> { - if interactive { - // Interactive mode - let user select and attach - use detached_shell::interactive::InteractivePicker; - - match InteractivePicker::new() { - Ok(mut picker) => { - match picker.run()? { - Some(session_id) => { - // User selected a session, attach to it - println!("Attaching to session {}...", session_id); - handle_attach_session(&session_id)?; - } - None => { - // User quit without selecting - println!("No session selected."); - } - } - } - Err(e) => { - eprintln!("Error: {}", e); - if matches!(e, NdsError::SessionNotFound(_)) { - println!("No active sessions found."); - } - } - } - } else { - // Normal list mode - let sessions = SessionManager::list_sessions()?; - let table = SessionTable::new(sessions); - table.print(); - } - Ok(()) -} - -fn handle_attach_session(session_id_or_name: &str) -> Result<()> { - // Allow partial ID or name matching - let sessions = SessionManager::list_sessions()?; - - // First try to match by ID - let mut matching_sessions: Vec<_> = sessions - .iter() - .filter(|s| s.id.starts_with(session_id_or_name)) - .collect(); - - // If no ID matches, try matching by name - if matching_sessions.is_empty() { - matching_sessions = sessions - .iter() - .filter(|s| { - if let Some(ref name) = s.name { - name == session_id_or_name || - name.starts_with(session_id_or_name) || - name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) - } else { - false - } - }) - .collect(); - } - - match matching_sessions.len() { - 0 => { - eprintln!("No session found matching ID or name: {}", session_id_or_name); - Err(NdsError::SessionNotFound(session_id_or_name.to_string())) - } - 1 => { - let session = matching_sessions[0]; - SessionManager::attach_session(&session.id)?; - Ok(()) - } - _ => { - eprintln!( - "Multiple sessions match '{}'. Please be more specific:", - session_id_or_name - ); - for session in matching_sessions { - eprintln!(" - {}", session.display_name()); - } - Err(NdsError::InvalidSessionId(session_id_or_name.to_string())) - } - } -} - -fn handle_kill_sessions(session_ids: &[String]) -> Result<()> { - if session_ids.is_empty() { - eprintln!("No session IDs provided"); - return Err(NdsError::SessionNotFound( - "No session IDs provided".to_string(), - )); - } - - let sessions = SessionManager::list_sessions()?; - let mut killed_count = 0; - let mut errors = Vec::new(); - - for session_id in session_ids { - match kill_single_session(session_id, &sessions) { - Ok(killed_id) => { - println!("Killed session: {}", killed_id); - killed_count += 1; - } - Err(e) => { - eprintln!("Error killing session '{}': {}", session_id, e); - errors.push(format!("{}: {}", session_id, e)); - } - } - } - - if killed_count > 0 { - println!("Successfully killed {} session(s)", killed_count); - } - - if !errors.is_empty() && killed_count == 0 { - Err(NdsError::SessionNotFound(errors.join(", "))) - } else { - Ok(()) - } -} - -fn kill_single_session(session_id_or_name: &str, sessions: &[Session]) -> Result { - // Allow partial ID or name matching - let mut matching_sessions: Vec<_> = sessions - .iter() - .filter(|s| s.id.starts_with(session_id_or_name)) - .collect(); - - // If no ID matches, try matching by name - if matching_sessions.is_empty() { - matching_sessions = sessions - .iter() - .filter(|s| { - if let Some(ref name) = s.name { - name == session_id_or_name || - name.starts_with(session_id_or_name) || - name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) - } else { - false - } - }) - .collect(); - } - - match matching_sessions.len() { - 0 => Err(NdsError::SessionNotFound(format!( - "No session found matching ID or name: {}", - session_id_or_name - ))), - 1 => { - let session = matching_sessions[0]; - SessionManager::kill_session(&session.id)?; - Ok(session.id.clone()) - } - _ => { - let matches: Vec = matching_sessions - .iter() - .map(|s| s.display_name()) - .collect(); - Err(NdsError::SessionNotFound(format!( - "Multiple sessions match '{}': {}. Please be more specific", - session_id_or_name, - matches.join(", ") - ))) + handlers::handle_list_sessions(true)?; } } -} - -fn handle_session_info(session_id_or_name: &str) -> Result<()> { - // Allow partial ID or name matching - let sessions = SessionManager::list_sessions()?; - - // First try to match by ID - let mut matching_sessions: Vec<_> = sessions - .iter() - .filter(|s| s.id.starts_with(session_id_or_name)) - .collect(); - - // If no ID matches, try matching by name - if matching_sessions.is_empty() { - matching_sessions = sessions - .iter() - .filter(|s| { - if let Some(ref name) = s.name { - name == session_id_or_name || - name.starts_with(session_id_or_name) || - name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) - } else { - false - } - }) - .collect(); - } - - match matching_sessions.len() { - 0 => { - eprintln!("No session found matching ID or name: {}", session_id_or_name); - Err(NdsError::SessionNotFound(session_id_or_name.to_string())) - } - 1 => { - let session = matching_sessions[0]; - let client_count = session.get_client_count(); - - println!("Session ID: {}", session.id); - if let Some(ref name) = session.name { - println!("Session Name: {}", name); - } - println!("PID: {}", session.pid); - println!("Created: {}", session.created_at); - println!("Socket: {}", session.socket_path.display()); - println!("Shell: {}", session.shell); - println!("Working Directory: {}", session.working_dir); - println!( - "Status: {}", - if client_count > 0 { - format!("Attached ({} client(s))", client_count) - } else { - "Detached".to_string() - } - ); - Ok(()) - } - _ => { - eprintln!( - "Multiple sessions match '{}'. Please be more specific:", - session_id_or_name - ); - for session in matching_sessions { - eprintln!(" - {}", session.display_name()); - } - Err(NdsError::InvalidSessionId(session_id_or_name.to_string())) - } - } -} - -fn handle_rename_session(session_id_or_name: &str, new_name: &str) -> Result<()> { - // Allow partial ID or name matching - let sessions = SessionManager::list_sessions()?; - - // First try to match by ID - let mut matching_sessions: Vec<_> = sessions - .iter() - .filter(|s| s.id.starts_with(session_id_or_name)) - .collect(); - - // If no ID matches, try matching by name - if matching_sessions.is_empty() { - matching_sessions = sessions - .iter() - .filter(|s| { - if let Some(ref name) = s.name { - name == session_id_or_name || - name.starts_with(session_id_or_name) || - name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) - } else { - false - } - }) - .collect(); - } - match matching_sessions.len() { - 0 => { - eprintln!("No session found matching ID or name: {}", session_id_or_name); - Err(NdsError::SessionNotFound(session_id_or_name.to_string())) - } - 1 => { - let session = matching_sessions[0]; - let old_display_name = session.display_name(); - SessionManager::rename_session(&session.id, new_name)?; - println!("Renamed session {} to '{}'", old_display_name, new_name); - Ok(()) - } - _ => { - eprintln!( - "Multiple sessions match '{}'. Please be more specific:", - session_id_or_name - ); - for session in matching_sessions { - eprintln!(" - {}", session.display_name()); - } - Err(NdsError::InvalidSessionId(session_id_or_name.to_string())) - } - } -} - -fn handle_clean_sessions() -> Result<()> { - println!("Cleaning up dead sessions..."); - SessionManager::cleanup_dead_sessions()?; - println!("Cleanup complete."); Ok(()) } -fn handle_session_history(session_id_or_name: Option, all: bool, limit: usize) -> Result<()> { - use chrono::{DateTime, Local}; - - // Migrate old format if needed - let _ = SessionHistory::migrate_from_single_file(); - - if let Some(ref id_or_name) = session_id_or_name { - // First try to resolve session name to ID - let sessions = SessionManager::list_sessions()?; - let resolved_id = if sessions.iter().any(|s| s.id == *id_or_name) { - // It's already a session ID - id_or_name.clone() - } else { - // Try to find by name (case-insensitive partial matching) - let matches: Vec<&Session> = sessions - .iter() - .filter(|s| { - if let Some(ref name) = s.name { - name.to_lowercase().contains(&id_or_name.to_lowercase()) - } else { - false - } - }) - .collect(); - - match matches.len() { - 0 => { - println!("No session found with ID or name matching: {}", id_or_name); - return Ok(()); - } - 1 => matches[0].id.clone(), - _ => { - println!("Multiple sessions match '{}'. Please be more specific:", id_or_name); - for session in matches { - println!(" {} [{}]", session.display_name(), session.id); - } - return Ok(()); - } - } - }; - - // Show history for specific session - let entries = SessionHistory::get_session_history(&resolved_id)?; - - if entries.is_empty() { - println!("No history found for session: {}", resolved_id); - return Ok(()); - } - - println!("History for session {}:", resolved_id); - println!("{:-<80}", ""); - - for entry in entries.iter().take(limit) { - let local_time: DateTime = entry.timestamp.into(); - let time_str = local_time.format("%Y-%m-%d %H:%M:%S").to_string(); - - let event_str = match &entry.event { - SessionEvent::Created => "Created".to_string(), - SessionEvent::Attached => "Attached".to_string(), - SessionEvent::Detached => "Detached".to_string(), - SessionEvent::Killed => format!( - "Killed (duration: {})", - entry - .duration_seconds - .map(SessionHistory::format_duration) - .unwrap_or_else(|| "unknown".to_string()) - ), - SessionEvent::Crashed => format!( - "Crashed (duration: {})", - entry - .duration_seconds - .map(SessionHistory::format_duration) - .unwrap_or_else(|| "unknown".to_string()) - ), - SessionEvent::Renamed { from, to } => match from { - Some(old) => format!("Renamed from '{}' to '{}'", old, to), - None => format!("Named as '{}'", to), - }, - }; - - println!( - "{} | {:<20} | PID: {} | {}", - time_str, event_str, entry.pid, entry.working_dir - ); - } - } else { - // Show all history or active sessions only - let entries = SessionHistory::load_all_history(all, Some(limit))?; - - let filtered_entries: Vec<_> = if !all { - // Filter to show only entries for currently active sessions - let active_sessions = SessionManager::list_sessions()?; - let active_ids: std::collections::HashSet<_> = - active_sessions.iter().map(|s| s.id.clone()).collect(); - - entries - .into_iter() - .filter(|e| active_ids.contains(&e.session_id)) - .collect() - } else { - entries - }; - - if filtered_entries.is_empty() { - if all { - println!("No session history found."); - } else { - println!("No history for active sessions. Use --all to see all history."); - } - return Ok(()); - } - - println!( - "{} Session History (showing {} entries)", - if all { "All" } else { "Active" }, - filtered_entries.len() - ); - println!("{:-<100}", ""); - println!( - "{:<20} {:<12} {:<20} {:<8} {:<10} {:<30}", - "Time", "Session", "Event", "PID", "Duration", "Working Dir" - ); - println!("{:-<100}", ""); - - for entry in filtered_entries { - let local_time: DateTime = entry.timestamp.into(); - let time_str = local_time.format("%Y-%m-%d %H:%M:%S").to_string(); - - let session_display = if let Some(ref name) = entry.session_name { - format!( - "{} [{}]", - name, - &entry.session_id[..8.min(entry.session_id.len())] - ) - } else { - entry.session_id[..8.min(entry.session_id.len())].to_string() - }; - - let (event_str, duration_str) = match &entry.event { - SessionEvent::Created => ("Created".to_string(), "-".to_string()), - SessionEvent::Attached => ("Attached".to_string(), "-".to_string()), - SessionEvent::Detached => ("Detached".to_string(), "-".to_string()), - SessionEvent::Killed => ( - "Killed".to_string(), - entry - .duration_seconds - .map(SessionHistory::format_duration) - .unwrap_or_else(|| "-".to_string()), - ), - SessionEvent::Crashed => ( - "Crashed".to_string(), - entry - .duration_seconds - .map(SessionHistory::format_duration) - .unwrap_or_else(|| "-".to_string()), - ), - SessionEvent::Renamed { .. } => ("Renamed".to_string(), "-".to_string()), - }; - - let working_dir = if entry.working_dir.len() > 30 { - format!("...{}", &entry.working_dir[entry.working_dir.len() - 27..]) - } else { - entry.working_dir.clone() - }; - - println!( - "{:<20} {:<12} {:<20} {:<8} {:<10} {:<30}", - time_str, session_display, event_str, entry.pid, duration_str, working_dir - ); - } - } - - Ok(()) -} From a510c7ecdf2a208a00ca8ad3c6d743d177cda594 Mon Sep 17 00:00:00 2001 From: Kerem Noras Date: Sat, 13 Sep 2025 12:47:51 +0300 Subject: [PATCH 4/4] style: Apply rustfmt formatting to all files - Fixed all rustfmt warnings for CI compliance - Consistent code formatting across the codebase - No functional changes, only formatting --- src/handlers/info.rs | 21 ++-- src/handlers/mod.rs | 13 +-- src/handlers/session.rs | 51 ++++++---- src/interactive.rs | 192 ++++++++++++++++++------------------ src/manager.rs | 56 ++++++++--- src/pty/client.rs | 14 +-- src/pty/io_handler.rs | 46 ++++----- src/pty/mod.rs | 6 +- src/pty/session_switcher.rs | 49 +++++---- src/pty/socket.rs | 10 +- src/pty/spawn.rs | 107 ++++++++++++-------- src/pty/terminal.rs | 63 ++++++------ 12 files changed, 343 insertions(+), 285 deletions(-) diff --git a/src/handlers/info.rs b/src/handlers/info.rs index a15cb6b..5cedc19 100644 --- a/src/handlers/info.rs +++ b/src/handlers/info.rs @@ -44,22 +44,24 @@ pub fn handle_list_sessions(interactive: bool) -> Result<()> { pub fn handle_session_info(session_id_or_name: &str) -> Result<()> { // Allow partial ID or name matching let sessions = SessionManager::list_sessions()?; - + // First try to match by ID let mut matching_sessions: Vec<_> = sessions .iter() .filter(|s| s.id.starts_with(session_id_or_name)) .collect(); - + // If no ID matches, try matching by name if matching_sessions.is_empty() { matching_sessions = sessions .iter() .filter(|s| { if let Some(ref name) = s.name { - name == session_id_or_name || - name.starts_with(session_id_or_name) || - name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + name == session_id_or_name + || name.starts_with(session_id_or_name) + || name + .to_lowercase() + .starts_with(&session_id_or_name.to_lowercase()) } else { false } @@ -69,13 +71,16 @@ pub fn handle_session_info(session_id_or_name: &str) -> Result<()> { match matching_sessions.len() { 0 => { - eprintln!("No session found matching ID or name: {}", session_id_or_name); + eprintln!( + "No session found matching ID or name: {}", + session_id_or_name + ); Err(NdsError::SessionNotFound(session_id_or_name.to_string())) } 1 => { let session = matching_sessions[0]; let client_count = session.get_client_count(); - + println!("Session ID: {}", session.id); if let Some(ref name) = session.name { println!("Session Name: {}", name); @@ -311,4 +316,4 @@ fn print_history_table(entries: &[detached_shell::HistoryEntry], all: bool) { time_str, session_display, event_str, entry.pid, duration_str, working_dir ); } -} \ No newline at end of file +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 6c3251e..d3046b3 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,18 +1,11 @@ // Module declarations -pub mod session; pub mod info; +pub mod session; // Re-export commonly used items for convenience pub use session::{ - handle_attach_session, - handle_clean_sessions, - handle_kill_sessions, - handle_new_session, + handle_attach_session, handle_clean_sessions, handle_kill_sessions, handle_new_session, handle_rename_session, }; -pub use info::{ - handle_list_sessions, - handle_session_history, - handle_session_info, -}; \ No newline at end of file +pub use info::{handle_list_sessions, handle_session_history, handle_session_info}; diff --git a/src/handlers/session.rs b/src/handlers/session.rs index 33e278d..9609373 100644 --- a/src/handlers/session.rs +++ b/src/handlers/session.rs @@ -38,22 +38,24 @@ pub fn handle_new_session(name: Option, attach: bool) -> Result<()> { pub fn handle_attach_session(session_id_or_name: &str) -> Result<()> { // Allow partial ID or name matching let sessions = SessionManager::list_sessions()?; - + // First try to match by ID let mut matching_sessions: Vec<_> = sessions .iter() .filter(|s| s.id.starts_with(session_id_or_name)) .collect(); - + // If no ID matches, try matching by name if matching_sessions.is_empty() { matching_sessions = sessions .iter() .filter(|s| { if let Some(ref name) = s.name { - name == session_id_or_name || - name.starts_with(session_id_or_name) || - name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + name == session_id_or_name + || name.starts_with(session_id_or_name) + || name + .to_lowercase() + .starts_with(&session_id_or_name.to_lowercase()) } else { false } @@ -63,7 +65,10 @@ pub fn handle_attach_session(session_id_or_name: &str) -> Result<()> { match matching_sessions.len() { 0 => { - eprintln!("No session found matching ID or name: {}", session_id_or_name); + eprintln!( + "No session found matching ID or name: {}", + session_id_or_name + ); Err(NdsError::SessionNotFound(session_id_or_name.to_string())) } 1 => { @@ -128,16 +133,18 @@ fn kill_single_session(session_id_or_name: &str, sessions: &[Session]) -> Result .iter() .filter(|s| s.id.starts_with(session_id_or_name)) .collect(); - + // If no ID matches, try matching by name if matching_sessions.is_empty() { matching_sessions = sessions .iter() .filter(|s| { if let Some(ref name) = s.name { - name == session_id_or_name || - name.starts_with(session_id_or_name) || - name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + name == session_id_or_name + || name.starts_with(session_id_or_name) + || name + .to_lowercase() + .starts_with(&session_id_or_name.to_lowercase()) } else { false } @@ -156,10 +163,7 @@ fn kill_single_session(session_id_or_name: &str, sessions: &[Session]) -> Result Ok(session.id.clone()) } _ => { - let matches: Vec = matching_sessions - .iter() - .map(|s| s.display_name()) - .collect(); + let matches: Vec = matching_sessions.iter().map(|s| s.display_name()).collect(); Err(NdsError::SessionNotFound(format!( "Multiple sessions match '{}': {}. Please be more specific", session_id_or_name, @@ -173,22 +177,24 @@ fn kill_single_session(session_id_or_name: &str, sessions: &[Session]) -> Result pub fn handle_rename_session(session_id_or_name: &str, new_name: &str) -> Result<()> { // Allow partial ID or name matching let sessions = SessionManager::list_sessions()?; - + // First try to match by ID let mut matching_sessions: Vec<_> = sessions .iter() .filter(|s| s.id.starts_with(session_id_or_name)) .collect(); - + // If no ID matches, try matching by name if matching_sessions.is_empty() { matching_sessions = sessions .iter() .filter(|s| { if let Some(ref name) = s.name { - name == session_id_or_name || - name.starts_with(session_id_or_name) || - name.to_lowercase().starts_with(&session_id_or_name.to_lowercase()) + name == session_id_or_name + || name.starts_with(session_id_or_name) + || name + .to_lowercase() + .starts_with(&session_id_or_name.to_lowercase()) } else { false } @@ -198,7 +204,10 @@ pub fn handle_rename_session(session_id_or_name: &str, new_name: &str) -> Result match matching_sessions.len() { 0 => { - eprintln!("No session found matching ID or name: {}", session_id_or_name); + eprintln!( + "No session found matching ID or name: {}", + session_id_or_name + ); Err(NdsError::SessionNotFound(session_id_or_name.to_string())) } 1 => { @@ -227,4 +236,4 @@ pub fn handle_clean_sessions() -> Result<()> { SessionManager::cleanup_dead_sessions()?; println!("Cleanup complete."); Ok(()) -} \ No newline at end of file +} diff --git a/src/interactive.rs b/src/interactive.rs index c825d80..c4cbf12 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -3,7 +3,9 @@ use chrono::Timelike; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, - terminal::{self, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{ + self, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, + }, }; use ratatui::{ backend::{Backend, CrosstermBackend}, @@ -33,22 +35,26 @@ impl InteractivePicker { let mut state = ListState::default(); state.select(Some(0)); - + // Check if we're currently attached to a session let mut current_session_id = std::env::var("NDS_SESSION_ID").ok(); - + // Fallback: If no environment variable, try to detect from parent processes if current_session_id.is_none() { current_session_id = Self::detect_current_session(&sessions); } - Ok(Self { sessions, state, current_session_id }) + Ok(Self { + sessions, + state, + current_session_id, + }) } fn detect_current_session(sessions: &[Session]) -> Option { // Try to detect current session by checking parent processes let mut ppid = std::process::id(); - + // Walk up the process tree (max 10 levels to avoid infinite loops) for _ in 0..10 { // Get parent process ID @@ -65,10 +71,10 @@ impl InteractivePicker { break; } } - + None } - + fn get_parent_pid(pid: i32) -> Option { // Read /proc/[pid]/stat on Linux or use ps on macOS #[cfg(target_os = "macos")] @@ -78,7 +84,7 @@ impl InteractivePicker { .args(&["-p", &pid.to_string(), "-o", "ppid="]) .output() .ok()?; - + if output.status.success() { let ppid_str = String::from_utf8_lossy(&output.stdout); ppid_str.trim().parse::().ok() @@ -86,7 +92,7 @@ impl InteractivePicker { None } } - + #[cfg(target_os = "linux")] { use std::fs; @@ -100,13 +106,13 @@ impl InteractivePicker { None } } - + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { None } } - + pub fn run(&mut self) -> Result> { // Setup terminal enable_raw_mode()?; @@ -228,56 +234,71 @@ impl InteractivePicker { // Check if this is the current attached session let is_current = self.current_session_id.as_ref() == Some(&session.id); - + // Status indicator - simplified let (status_icon, status_color) = if is_current { ("โ˜…", Color::Cyan) } else if client_count > 0 { - ("โ—", Color::Green) + ("โ—", Color::Green) } else { ("โ—‹", Color::Gray) }; - + // Session name styling let name_style = if is_current { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; - + // Build the status text that appears on the right let status_text = if is_current { if client_count > 0 { - format!("CURRENT SESSION ยท {} CLIENT{}", client_count, if client_count == 1 { "" } else { "S" }) + format!( + "CURRENT SESSION ยท {} CLIENT{}", + client_count, + if client_count == 1 { "" } else { "S" } + ) } else { "CURRENT SESSION".to_string() } } else if client_count > 0 { - format!("{} CLIENT{}", client_count, if client_count == 1 { "" } else { "S" }) + format!( + "{} CLIENT{}", + client_count, + if client_count == 1 { "" } else { "S" } + ) } else { "DETACHED".to_string() }; - + // Format created time let now = chrono::Local::now(); let local_time: chrono::DateTime = session.created_at.into(); let duration = now.signed_duration_since(local_time); - + let created_time = if duration.num_days() > 0 { - format!("{}d, {:02}:{:02}", - duration.num_days(), - local_time.hour(), - local_time.minute()) + format!( + "{}d, {:02}:{:02}", + duration.num_days(), + local_time.hour(), + local_time.minute() + ) } else { local_time.format("%H:%M:%S").to_string() }; - + // Truncate working dir if too long let mut working_dir = session.working_dir.clone(); if working_dir.len() > 30 { - working_dir = format!("...{}", &session.working_dir[session.working_dir.len() - 27..]); + working_dir = format!( + "...{}", + &session.working_dir[session.working_dir.len() - 27..] + ); } - + // Build left side with fixed widths let left_side = format!( " {} {:<25} โ”‚ PID {:<6} โ”‚ {:<8} โ”‚ {:<8} โ”‚ {:<30}", @@ -288,80 +309,63 @@ impl InteractivePicker { created_time, working_dir ); - + // Calculate padding for right alignment let terminal_width = terminal::size().unwrap_or((80, 24)).0 as usize; let left_len = left_side.chars().count(); let status_len = status_text.chars().count(); let padding = terminal_width.saturating_sub(left_len + status_len + 2); - let content = vec![ - Line::from(vec![ - Span::styled( - format!(" {} ", status_icon), - Style::default().fg(status_color).add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{:<25}", session.display_name()), - name_style, - ), - Span::styled( - " โ”‚ ", - Style::default().fg(Color::DarkGray), - ), - Span::styled( - format!("PID {:<6}", session.pid), - Style::default().fg(Color::DarkGray), - ), - Span::styled( - " โ”‚ ", - Style::default().fg(Color::DarkGray), - ), - Span::styled( - format!("{:<8}", uptime), - Style::default().fg(Color::DarkGray), - ), - Span::styled( - " โ”‚ ", - Style::default().fg(Color::DarkGray), - ), - Span::styled( - format!("{:<8}", created_time), - Style::default().fg(Color::DarkGray), - ), - Span::styled( - " โ”‚ ", - Style::default().fg(Color::DarkGray), - ), - Span::styled( - format!("{:<30}", working_dir), - Style::default().fg(Color::DarkGray), - ), - Span::styled( - " ".repeat(padding), - Style::default(), - ), - Span::styled( - status_text.clone(), - if is_current { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) - } else if client_count > 0 { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM) - }, - ), - ]), - ]; + let content = vec![Line::from(vec![ + Span::styled( + format!(" {} ", status_icon), + Style::default() + .fg(status_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled(format!("{:<25}", session.display_name()), name_style), + Span::styled(" โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("PID {:<6}", session.pid), + Style::default().fg(Color::DarkGray), + ), + Span::styled(" โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{:<8}", uptime), + Style::default().fg(Color::DarkGray), + ), + Span::styled(" โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{:<8}", created_time), + Style::default().fg(Color::DarkGray), + ), + Span::styled(" โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{:<30}", working_dir), + Style::default().fg(Color::DarkGray), + ), + Span::styled(" ".repeat(padding), Style::default()), + Span::styled( + status_text.clone(), + if is_current { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if client_count > 0 { + Style::default().fg(Color::Green) + } else { + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM) + }, + ), + ])]; ListItem::new(content) }) .collect(); let sessions_list = List::new(items) - .block( - Block::default() - .borders(Borders::NONE), - ) + .block(Block::default().borders(Borders::NONE)) .highlight_style( Style::default() .bg(Color::Rgb(40, 40, 40)) @@ -382,9 +386,9 @@ impl InteractivePicker { Span::styled("q ", Style::default().fg(Color::DarkGray)), Span::styled("quit", Style::default().fg(Color::Gray)), ]; - + let session_info = format!("{} sessions", self.sessions.len()); - + let footer = Paragraph::new(Line::from(help_text)) .style(Style::default()) .alignment(Alignment::Center) @@ -394,7 +398,7 @@ impl InteractivePicker { .border_style(Style::default().fg(Color::DarkGray)), ); f.render_widget(footer, chunks[2]); - + // Session count on the right let count_widget = Paragraph::new(session_info) .style(Style::default().fg(Color::DarkGray)) @@ -419,4 +423,4 @@ fn format_duration(seconds: u64) -> String { } else { format!("{}d {}h", seconds / 86400, (seconds % 86400) / 3600) } -} \ No newline at end of file +} diff --git a/src/manager.rs b/src/manager.rs index 1722d52..3a97296 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -132,11 +132,17 @@ pub struct SessionDisplay<'a> { impl<'a> SessionDisplay<'a> { pub fn new(session: &'a Session) -> Self { - SessionDisplay { session, is_current: false } + SessionDisplay { + session, + is_current: false, + } } - + pub fn with_current(session: &'a Session, is_current: bool) -> Self { - SessionDisplay { session, is_current } + SessionDisplay { + session, + is_current, + } } fn format_duration(&self) -> String { @@ -158,12 +164,14 @@ impl<'a> SessionDisplay<'a> { let now = Local::now(); let local_time: DateTime = self.session.created_at.into(); let duration = now.signed_duration_since(local_time); - + if duration.num_days() > 0 { - format!("{}d, {:02}:{:02}", - duration.num_days(), - local_time.hour(), - local_time.minute()) + format!( + "{}d, {:02}:{:02}", + duration.num_days(), + local_time.hour(), + local_time.minute() + ) } else { local_time.format("%H:%M:%S").to_string() } @@ -174,21 +182,38 @@ impl<'a> fmt::Display for SessionDisplay<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Get client count let client_count = self.session.get_client_count(); - + // Status icon and color let (icon, status_text) = if self.is_current { - ("โ˜…", format!("CURRENT ยท {} client{}", client_count, if client_count == 1 { "" } else { "s" })) + ( + "โ˜…", + format!( + "CURRENT ยท {} client{}", + client_count, + if client_count == 1 { "" } else { "s" } + ), + ) } else if client_count > 0 { - ("โ—", format!("{} client{}", client_count, if client_count == 1 { "" } else { "s" })) + ( + "โ—", + format!( + "{} client{}", + client_count, + if client_count == 1 { "" } else { "s" } + ), + ) } else { ("โ—‹", "detached".to_string()) }; - + // Truncate working dir if too long let mut working_dir = self.session.working_dir.clone(); if working_dir.len() > 30 { // Show last 27 chars with ellipsis - working_dir = format!("...{}", &self.session.working_dir[self.session.working_dir.len() - 27..]); + working_dir = format!( + "...{}", + &self.session.working_dir[self.session.working_dir.len() - 27..] + ); } // Format with sleek layout including all info @@ -215,7 +240,10 @@ impl SessionTable { pub fn new(sessions: Vec) -> Self { // Check if we're currently attached to a session let current_session_id = std::env::var("NDS_SESSION_ID").ok(); - SessionTable { sessions, current_session_id } + SessionTable { + sessions, + current_session_id, + } } pub fn print(&self) { diff --git a/src/pty/client.rs b/src/pty/client.rs index 7b017ae..d7227ec 100644 --- a/src/pty/client.rs +++ b/src/pty/client.rs @@ -1,5 +1,5 @@ -use std::os::unix::net::UnixStream; use nix::libc; +use std::os::unix::net::UnixStream; // Structure to track client information #[derive(Debug)] @@ -13,14 +13,10 @@ impl ClientInfo { pub fn new(stream: UnixStream) -> Self { // Get initial terminal size let (rows, cols) = get_terminal_size().unwrap_or((24, 80)); - - Self { - stream, - rows, - cols, - } + + Self { stream, rows, cols } } - + pub fn update_size(&mut self, rows: u16, cols: u16) { self.rows = rows; self.cols = cols; @@ -35,4 +31,4 @@ pub fn get_terminal_size() -> Result<(u16, u16), std::io::Error> { } Ok((size.ws_row, size.ws_col)) } -} \ No newline at end of file +} diff --git a/src/pty/io_handler.rs b/src/pty/io_handler.rs index 761040a..2a9a394 100644 --- a/src/pty/io_handler.rs +++ b/src/pty/io_handler.rs @@ -1,8 +1,8 @@ use std::fs::File; use std::io::{self, Read, Write}; use std::os::unix::io::{FromRawFd, RawFd}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::sync::Mutex; use std::thread; use std::time::Duration; @@ -22,16 +22,16 @@ impl PtyIoHandler { buffer_size: 4096, } } - + /// Read from PTY master file descriptor pub fn read_from_pty(&self, buffer: &mut [u8]) -> io::Result { let master_file = unsafe { File::from_raw_fd(self.master_fd) }; let mut master_file_clone = master_file.try_clone()?; std::mem::forget(master_file); // Don't close the fd - + master_file_clone.read(buffer) } - + /// Write to PTY master file descriptor pub fn write_to_pty(&self, data: &[u8]) -> io::Result<()> { let mut master_file = unsafe { File::from_raw_fd(self.master_fd) }; @@ -39,12 +39,12 @@ impl PtyIoHandler { std::mem::forget(master_file); // Don't close the fd result } - + /// Send a control character to the PTY pub fn send_control_char(&self, ch: u8) -> io::Result<()> { self.write_to_pty(&[ch]) } - + /// Send Ctrl+L to refresh the display pub fn send_refresh(&self) -> io::Result<()> { self.send_control_char(0x0c) // Ctrl+L @@ -64,24 +64,24 @@ impl ScrollbackHandler { max_size, } } - + /// Add data to the scrollback buffer pub fn add_data(&self, data: &[u8]) { let mut buffer = self.buffer.lock().unwrap(); buffer.extend_from_slice(data); - + // Trim if too large if buffer.len() > self.max_size { let remove = buffer.len() - self.max_size; buffer.drain(..remove); } } - + /// Get a clone of the scrollback buffer pub fn get_buffer(&self) -> Vec { self.buffer.lock().unwrap().clone() } - + /// Get a reference to the shared buffer pub fn get_shared_buffer(&self) -> Arc>> { Arc::clone(&self.buffer) @@ -97,7 +97,7 @@ pub fn spawn_socket_to_stdout_thread( thread::spawn(move || { let mut stdout = io::stdout(); let mut buffer = [0u8; 4096]; - + while running.load(Ordering::SeqCst) { match socket.read(&mut buffer) { Ok(0) => break, // Socket closed @@ -107,11 +107,11 @@ pub fn spawn_socket_to_stdout_thread( break; } let _ = stdout.flush(); - + // Add to scrollback buffer let mut scrollback = scrollback.lock().unwrap(); scrollback.extend_from_slice(&buffer[..n]); - + // Trim if too large let scrollback_max = 10 * 1024 * 1024; // 10MB if scrollback.len() > scrollback_max { @@ -140,10 +140,10 @@ pub fn spawn_resize_monitor_thread( ) -> thread::JoinHandle<()> { use crate::pty::socket::send_resize_command; use crossterm::terminal; - + thread::spawn(move || { let mut last_size = initial_size; - + while running.load(Ordering::SeqCst) { if let Ok((new_cols, new_rows)) = terminal::size() { if (new_cols, new_rows) != last_size { @@ -166,36 +166,36 @@ pub fn send_buffered_output( if !output_buffer.is_empty() { let mut buffered_data = Vec::new(); output_buffer.drain_to(&mut buffered_data); - + // Save cursor position, clear screen, and reset let init_sequence = b"\x1b7\x1b[?47h\x1b[2J\x1b[H"; // Save cursor, alt screen, clear, home stream.write_all(init_sequence)?; stream.flush()?; - + // Send buffered data in chunks to avoid overwhelming the client for chunk in buffered_data.chunks(4096) { stream.write_all(chunk)?; stream.flush()?; thread::sleep(Duration::from_millis(1)); } - + // Exit alt screen and restore cursor let restore_sequence = b"\x1b[?47l\x1b8"; // Exit alt screen, restore cursor stream.write_all(restore_sequence)?; stream.flush()?; - + // Small delay for terminal to process thread::sleep(Duration::from_millis(50)); - + // Send a full redraw command to the shell io_handler.send_refresh()?; - + // Give time for the refresh to complete thread::sleep(Duration::from_millis(100)); } else { // No buffer, just request a refresh to sync state io_handler.send_refresh()?; } - + Ok(()) -} \ No newline at end of file +} diff --git a/src/pty/mod.rs b/src/pty/mod.rs index 85e4de1..8d99ed5 100644 --- a/src/pty/mod.rs +++ b/src/pty/mod.rs @@ -1,14 +1,14 @@ // PTY process management module mod client; -mod socket; -mod terminal; mod io_handler; mod session_switcher; +mod socket; mod spawn; +mod terminal; // Re-export main types for backward compatibility pub use spawn::PtyProcess; // Note: ClientInfo is now internal to the module // If it needs to be public, uncomment the line below: -// pub use client::ClientInfo; \ No newline at end of file +// pub use client::ClientInfo; diff --git a/src/pty/session_switcher.rs b/src/pty/session_switcher.rs index b1543ab..62c3f11 100644 --- a/src/pty/session_switcher.rs +++ b/src/pty/session_switcher.rs @@ -1,11 +1,11 @@ -use std::io::{self, Write, BufRead}; +use std::io::{self, BufRead, Write}; use std::os::unix::io::{BorrowedFd, RawFd}; use nix::sys::termios::{tcgetattr, tcsetattr, SetArg, Termios}; use crate::error::{NdsError, Result}; -use crate::session::Session; use crate::manager::SessionManager; +use crate::session::Session; /// Result of a session switch operation pub enum SwitchResult { @@ -34,43 +34,38 @@ impl<'a> SessionSwitcher<'a> { original_termios, } } - + /// Show the session switcher interface and handle user selection pub fn show_switcher(&self) -> Result { println!("\r\n[Session Switcher]\r"); - + // Get list of other sessions let sessions = SessionManager::list_sessions()?; let other_sessions: Vec<_> = sessions .iter() .filter(|s| s.id != self.current_session.id) .collect(); - + // Show available sessions println!("\r\nAvailable options:\r"); - + // Show existing sessions if !other_sessions.is_empty() { for (i, s) in other_sessions.iter().enumerate() { - println!( - "\r {}. {} (PID: {})\r", - i + 1, - s.display_name(), - s.pid - ); + println!("\r {}. {} (PID: {})\r", i + 1, s.display_name(), s.pid); } } - + // Add new session option let new_option_num = other_sessions.len() + 1; println!("\r {}. [New Session]\r", new_option_num); println!("\r 0. Cancel\r"); println!("\r\nSelect option (0-{}): ", new_option_num); let _ = io::stdout().flush(); - + // Read user selection with temporary cooked mode let selection = self.read_user_input()?; - + if let Ok(num) = selection.trim().parse::() { if num > 0 && num <= other_sessions.len() { // Switch to selected session @@ -82,26 +77,26 @@ impl<'a> SessionSwitcher<'a> { return self.handle_new_session(); } } - + // Cancelled or invalid selection println!("\r\n[Continuing current session]\r"); Ok(SwitchResult::Continue) } - + /// Handle creating a new session fn handle_new_session(&self) -> Result { println!("\r\nEnter name for new session (or press Enter for no name): "); let _ = io::stdout().flush(); - + let session_name = self.read_user_input()?; let session_name = session_name.trim(); - + let name = if session_name.is_empty() { None } else { Some(session_name.to_string()) }; - + // Create new session match SessionManager::create_session_with_name(name.clone()) { Ok(new_session) => { @@ -124,25 +119,25 @@ impl<'a> SessionSwitcher<'a> { } } } - + /// Read user input with temporary cooked mode fn read_user_input(&self) -> Result { let stdin_borrowed = unsafe { BorrowedFd::borrow_raw(self.stdin_fd) }; - + // Save current raw mode settings let current_termios = tcgetattr(&stdin_borrowed)?; - + // Restore to original (cooked) mode for line input tcsetattr(&stdin_borrowed, SetArg::TCSANOW, self.original_termios)?; - + // Read user input let stdin = io::stdin(); let mut buffer = String::new(); let read_result = stdin.lock().read_line(&mut buffer); - + // Restore raw mode tcsetattr(&stdin_borrowed, SetArg::TCSANOW, ¤t_termios)?; - + read_result.map_err(|e| NdsError::Io(e))?; Ok(buffer) } @@ -156,4 +151,4 @@ pub fn show_session_help() { println!("\r ~h - Show scrollback history\r"); println!("\r ~~ - Send literal tilde\r"); println!("\r\n[Press any key to continue]\r"); -} \ No newline at end of file +} diff --git a/src/pty/socket.rs b/src/pty/socket.rs index a3caf77..be96f7e 100644 --- a/src/pty/socket.rs +++ b/src/pty/socket.rs @@ -1,6 +1,6 @@ +use std::io::{self, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; -use std::io::{self, Write}; use crate::error::{NdsError, Result}; use crate::session::Session; @@ -8,15 +8,15 @@ use crate::session::Session; /// Creates a Unix socket listener for a session pub fn create_listener(session_id: &str) -> Result<(UnixListener, PathBuf)> { let socket_path = Session::socket_dir()?.join(format!("{}.sock", session_id)); - + // Remove socket if it exists if socket_path.exists() { std::fs::remove_file(&socket_path)?; } - + let listener = UnixListener::bind(&socket_path) .map_err(|e| NdsError::SocketError(format!("Failed to bind socket: {}", e)))?; - + Ok((listener, socket_path)) } @@ -57,4 +57,4 @@ pub fn get_command_end(data: &[u8]) -> Option { } } None -} \ No newline at end of file +} diff --git a/src/pty/spawn.rs b/src/pty/spawn.rs index 6be18b4..bd2949b 100644 --- a/src/pty/spawn.rs +++ b/src/pty/spawn.rs @@ -13,19 +13,21 @@ use nix::sys::signal::{kill, Signal}; use nix::sys::termios::Termios; use nix::unistd::{close, dup2, execvp, fork, setsid, ForkResult, Pid}; -use crate::error::{NdsError, Result}; -use crate::pty_buffer::PtyBuffer; -use crate::scrollback::ScrollbackViewer; -use crate::session::Session; use super::client::ClientInfo; -use super::io_handler::{PtyIoHandler, ScrollbackHandler, spawn_socket_to_stdout_thread, spawn_resize_monitor_thread, send_buffered_output}; +use super::io_handler::{ + send_buffered_output, spawn_resize_monitor_thread, spawn_socket_to_stdout_thread, PtyIoHandler, + ScrollbackHandler, +}; use super::session_switcher::{SessionSwitcher, SwitchResult}; -use super::socket::{create_listener, send_resize_command, parse_nds_command, get_command_end}; +use super::socket::{create_listener, get_command_end, parse_nds_command, send_resize_command}; use super::terminal::{ - save_terminal_state, set_raw_mode, restore_terminal, get_terminal_size, - set_terminal_size, set_stdin_nonblocking, send_refresh, send_terminal_refresh_sequences, - capture_terminal_state + capture_terminal_state, get_terminal_size, restore_terminal, save_terminal_state, send_refresh, + send_terminal_refresh_sequences, set_raw_mode, set_stdin_nonblocking, set_terminal_size, }; +use crate::error::{NdsError, Result}; +use crate::pty_buffer::PtyBuffer; +use crate::scrollback::ScrollbackViewer; +use crate::session::Session; pub struct PtyProcess { pub master_fd: RawFd, @@ -240,7 +242,7 @@ impl PtyProcess { } else { std::env::set_var("NDS_SESSION_NAME", session_id); } - + // Get shell let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); @@ -261,8 +263,11 @@ impl PtyProcess { pub fn attach_to_session(session: &Session) -> Result> { // Set environment variables std::env::set_var("NDS_SESSION_ID", &session.id); - std::env::set_var("NDS_SESSION_NAME", session.name.as_ref().unwrap_or(&session.id)); - + std::env::set_var( + "NDS_SESSION_NAME", + session.name.as_ref().unwrap_or(&session.id), + ); + // Save current terminal state let stdin_fd = 0; let original_termios = save_terminal_state(stdin_fd)?; @@ -303,23 +308,23 @@ impl PtyProcess { let scrollback = ScrollbackHandler::new(10 * 1024 * 1024); // 10MB // Spawn resize monitor thread - let socket_for_resize = socket.try_clone() + let socket_for_resize = socket + .try_clone() .map_err(|e| NdsError::SocketError(format!("Failed to clone socket: {}", e)))?; let resize_running = running.clone(); - let _resize_monitor = spawn_resize_monitor_thread(socket_for_resize, resize_running, (cols, rows)); + let _resize_monitor = + spawn_resize_monitor_thread(socket_for_resize, resize_running, (cols, rows)); // Spawn socket to stdout thread - let socket_clone = socket.try_clone() + let socket_clone = socket + .try_clone() .map_err(|e| NdsError::SocketError(format!("Failed to clone socket: {}", e)))?; - let socket_to_stdout = spawn_socket_to_stdout_thread( - socket_clone, - r2, - scrollback.get_shared_buffer() - ); + let socket_to_stdout = + spawn_socket_to_stdout_thread(socket_clone, r2, scrollback.get_shared_buffer()); // Set stdin to non-blocking set_stdin_nonblocking(stdin_fd)?; - + // Main input loop let result = Self::handle_input_loop( &mut socket, @@ -342,7 +347,7 @@ impl PtyProcess { // Clear environment variables std::env::remove_var("NDS_SESSION_ID"); std::env::remove_var("NDS_SESSION_NAME"); - + println!("\n[Detached from session {}]", session.id); let _ = io::stdout().flush(); @@ -378,8 +383,13 @@ impl PtyProcess { break; } Ok(n) => { - let (should_detach, should_switch, should_scroll, data_to_forward) = - Self::process_input(&buffer[..n], &mut at_line_start, &mut escape_state, &mut escape_time); + let (should_detach, should_switch, should_scroll, data_to_forward) = + Self::process_input( + &buffer[..n], + &mut at_line_start, + &mut escape_state, + &mut escape_time, + ); if should_detach { println!("\r\n[Detaching from session {}]\r", session.id); @@ -501,7 +511,8 @@ impl PtyProcess { data_to_forward.push(b'~'); data_to_forward.push(byte); *escape_state = 0; - *at_line_start = byte == b'\r' || byte == b'\n' || byte == 10 || byte == 13; + *at_line_start = + byte == b'\r' || byte == b'\n' || byte == 10 || byte == 13; } } } @@ -521,7 +532,7 @@ impl PtyProcess { ) -> Result<()> { use nix::sys::termios::{tcsetattr, SetArg}; use std::os::unix::io::BorrowedFd; - + println!("\r\n[Opening scrollback viewer...]\r"); // Get scrollback content @@ -530,10 +541,10 @@ impl PtyProcess { // Temporarily restore terminal for viewer let stdin_fd = 0; let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - + // Get current raw mode settings let raw_termios = nix::sys::termios::tcgetattr(&stdin)?; - + // Restore to original mode for viewer tcsetattr(&stdin, SetArg::TCSANOW, original_termios)?; @@ -553,7 +564,9 @@ impl PtyProcess { /// Run the detached PTY handler pub fn run_detached(mut self) -> Result<()> { - let listener = self.listener.take() + let listener = self + .listener + .take() .ok_or_else(|| NdsError::PtyError("No listener available".to_string()))?; // Set listener to non-blocking @@ -568,7 +581,9 @@ impl PtyProcess { }) .map_err(|e| NdsError::SignalError(format!("Failed to set signal handler: {}", e)))?; - let output_buffer = self.output_buffer.take() + let output_buffer = self + .output_buffer + .take() .ok_or_else(|| NdsError::PtyError("No output buffer available".to_string()))?; // Support multiple concurrent clients @@ -576,7 +591,8 @@ impl PtyProcess { let mut buffer = [0u8; 4096]; // Get session ID from socket path - let session_id = self.socket_path + let session_id = self + .socket_path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") @@ -657,7 +673,11 @@ impl PtyProcess { Ok(()) } - fn read_from_pty(&self, io_handler: &PtyIoHandler, buffer: &mut [u8]) -> Result>> { + fn read_from_pty( + &self, + io_handler: &PtyIoHandler, + buffer: &mut [u8], + ) -> Result>> { match io_handler.read_from_pty(buffer) { Ok(0) => Err(NdsError::PtyError("Child process exited".to_string())), Ok(n) => Ok(Some(buffer[..n].to_vec())), @@ -714,7 +734,6 @@ impl PtyProcess { disconnected_indices: Vec, session_id: &str, ) -> Result<()> { - for i in disconnected_indices.iter().rev() { active_clients.remove(*i); } @@ -728,13 +747,13 @@ impl PtyProcess { "\r\n[A client disconnected (remaining: {})]\r\n", active_clients.len() ); - + for client in active_clients.iter_mut() { let _ = client.stream.write_all(notification.as_bytes()); let _ = send_terminal_refresh_sequences(&mut client.stream); let _ = client.stream.flush(); } - + // Resize to smallest terminal self.resize_to_smallest(active_clients)?; } @@ -744,16 +763,16 @@ impl PtyProcess { fn resize_to_smallest(&self, active_clients: &[ClientInfo]) -> Result<()> { let mut min_cols = u16::MAX; let mut min_rows = u16::MAX; - + for client in active_clients { min_cols = min_cols.min(client.cols); min_rows = min_rows.min(client.rows); } - + if min_cols != u16::MAX && min_rows != u16::MAX { set_terminal_size(self.master_fd, min_cols, min_rows)?; let _ = kill(self.pid, Signal::SIGWINCH); - + // Send refresh let io_handler = PtyIoHandler::new(self.master_fd); let _ = io_handler.send_refresh(); @@ -777,16 +796,18 @@ impl PtyProcess { } Ok(n) => { let data = &client_buffer[..n]; - + // Check for NDS commands if let Some((cmd, args)) = parse_nds_command(data) { if cmd == "resize" && args.len() == 2 { - if let (Ok(cols), Ok(rows)) = (args[0].parse::(), args[1].parse::()) { + if let (Ok(cols), Ok(rows)) = + (args[0].parse::(), args[1].parse::()) + { client.cols = cols; client.rows = rows; set_terminal_size(self.master_fd, cols, rows)?; let _ = kill(self.pid, Signal::SIGWINCH); - + // Forward any remaining data after command if let Some(end_idx) = get_command_end(data) { if end_idx < n { @@ -797,7 +818,7 @@ impl PtyProcess { } } } - + // Normal data - forward to PTY io_handler.write_to_pty(data)?; } @@ -863,4 +884,4 @@ pub fn spawn_new_detached_with_name(session_id: &str, name: Option) -> R pub fn kill_session(session_id: &str) -> Result<()> { PtyProcess::kill_session(session_id) -} \ No newline at end of file +} diff --git a/src/pty/terminal.rs b/src/pty/terminal.rs index 0321ced..c461ddd 100644 --- a/src/pty/terminal.rs +++ b/src/pty/terminal.rs @@ -1,11 +1,13 @@ +use std::io::{self, Write}; use std::os::unix::io::{BorrowedFd, RawFd}; use std::thread; use std::time::Duration; -use std::io::{self, Write}; use crossterm::terminal; use nix::sys::termios::{tcflush, tcgetattr, tcsetattr, FlushArg, SetArg, Termios}; -use nix::sys::termios::{InputFlags, OutputFlags, ControlFlags, LocalFlags, SpecialCharacterIndices}; +use nix::sys::termios::{ + ControlFlags, InputFlags, LocalFlags, OutputFlags, SpecialCharacterIndices, +}; use crate::error::{NdsError, Result}; use crate::terminal_state::TerminalState; @@ -13,15 +15,14 @@ use crate::terminal_state::TerminalState; /// Save the current terminal state pub fn save_terminal_state(stdin_fd: RawFd) -> Result { let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - tcgetattr(&stdin).map_err(|e| { - NdsError::TerminalError(format!("Failed to get terminal attributes: {}", e)) - }) + tcgetattr(&stdin) + .map_err(|e| NdsError::TerminalError(format!("Failed to get terminal attributes: {}", e))) } /// Set terminal to raw mode pub fn set_raw_mode(stdin_fd: RawFd, original: &Termios) -> Result<()> { let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - + let mut raw = original.clone(); // Manually set raw mode flags raw.input_flags = InputFlags::empty(); @@ -30,7 +31,7 @@ pub fn set_raw_mode(stdin_fd: RawFd, original: &Termios) -> Result<()> { raw.local_flags = LocalFlags::empty(); raw.control_chars[SpecialCharacterIndices::VMIN as usize] = 1; raw.control_chars[SpecialCharacterIndices::VTIME as usize] = 0; - + tcsetattr(&stdin, SetArg::TCSANOW, &raw) .map_err(|e| NdsError::TerminalError(format!("Failed to set raw mode: {}", e))) } @@ -38,31 +39,32 @@ pub fn set_raw_mode(stdin_fd: RawFd, original: &Termios) -> Result<()> { /// Restore terminal to original state pub fn restore_terminal(stdin_fd: RawFd, original: &Termios) -> Result<()> { let stdin = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - + // First restore stdin to blocking mode unsafe { let flags = libc::fcntl(stdin_fd, libc::F_GETFL); libc::fcntl(stdin_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); } - + // Clear any pending input from stdin buffer tcflush(&stdin, FlushArg::TCIFLUSH) .map_err(|e| NdsError::TerminalError(format!("Failed to flush stdin: {}", e)))?; - + // Restore the terminal settings tcsetattr(&stdin, SetArg::TCSANOW, original) .map_err(|e| NdsError::TerminalError(format!("Failed to restore terminal: {}", e)))?; - + // Ensure we're back in cooked mode terminal::disable_raw_mode().ok(); - + // Clear any remaining input after terminal restore - tcflush(&stdin, FlushArg::TCIFLUSH) - .map_err(|e| NdsError::TerminalError(format!("Failed to flush stdin after restore: {}", e)))?; - + tcflush(&stdin, FlushArg::TCIFLUSH).map_err(|e| { + NdsError::TerminalError(format!("Failed to flush stdin after restore: {}", e)) + })?; + // Add a small delay to ensure terminal is fully restored thread::sleep(Duration::from_millis(50)); - + Ok(()) } @@ -81,7 +83,9 @@ pub fn set_terminal_size(fd: RawFd, cols: u16, rows: u16) -> Result<()> { ws_ypixel: 0, }; if libc::ioctl(fd, libc::TIOCSWINSZ as u64, &winsize) < 0 { - return Err(NdsError::PtyError("Failed to set terminal size".to_string())); + return Err(NdsError::PtyError( + "Failed to set terminal size".to_string(), + )); } } Ok(()) @@ -92,7 +96,9 @@ pub fn set_stdin_nonblocking(stdin_fd: RawFd) -> Result<()> { unsafe { let flags = libc::fcntl(stdin_fd, libc::F_GETFL); if libc::fcntl(stdin_fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { - return Err(NdsError::TerminalError("Failed to set stdin non-blocking".to_string())); + return Err(NdsError::TerminalError( + "Failed to set stdin non-blocking".to_string(), + )); } } Ok(()) @@ -108,15 +114,16 @@ pub fn send_refresh(stream: &mut impl Write) -> io::Result<()> { /// Send terminal refresh sequences to restore normal state pub fn send_terminal_refresh_sequences(stream: &mut impl Write) -> io::Result<()> { let refresh_sequences = [ - "\x1b[?25h", // Show cursor - "\x1b[?12h", // Enable cursor blinking - "\x1b[1 q", // Blinking block cursor (default) - "\x1b[m", // Reset all attributes - "\x1b[?1000l", // Disable mouse tracking (if enabled) - "\x1b[?1002l", // Disable cell motion mouse tracking - "\x1b[?1003l", // Disable all motion mouse tracking - ].join(""); - + "\x1b[?25h", // Show cursor + "\x1b[?12h", // Enable cursor blinking + "\x1b[1 q", // Blinking block cursor (default) + "\x1b[m", // Reset all attributes + "\x1b[?1000l", // Disable mouse tracking (if enabled) + "\x1b[?1002l", // Disable cell motion mouse tracking + "\x1b[?1003l", // Disable all motion mouse tracking + ] + .join(""); + stream.write_all(refresh_sequences.as_bytes())?; stream.flush() } @@ -124,4 +131,4 @@ pub fn send_terminal_refresh_sequences(stream: &mut impl Write) -> io::Result<() /// Capture current terminal state for restoration pub fn capture_terminal_state(stdin_fd: RawFd) -> Result { TerminalState::capture(stdin_fd) -} \ No newline at end of file +}