detached_shell/
manager.rs

1use chrono::{DateTime, Local, Timelike, Utc};
2use std::fmt;
3
4use crate::error::{NdsError, Result};
5use crate::history_v2::SessionHistory;
6use crate::pty::PtyProcess;
7use crate::session::Session;
8
9pub struct SessionManager;
10
11impl SessionManager {
12    pub fn create_session() -> Result<Session> {
13        Self::create_session_with_name(None)
14    }
15
16    pub fn create_session_with_name(name: Option<String>) -> Result<Session> {
17        // Generate session ID
18        let session_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
19
20        // Spawn new PTY process with optional name
21        let session = PtyProcess::spawn_new_detached_with_name(&session_id, name)?;
22
23        // Record session creation in history
24        let _ = SessionHistory::record_session_created(&session);
25
26        Ok(session)
27    }
28
29    pub fn attach_session(session_id: &str) -> Result<()> {
30        let mut current_session_id = session_id.to_string();
31
32        loop {
33            // Load session metadata
34            let mut session = Session::load(&current_session_id)?;
35
36            // Validate session is still alive before attempting to attach
37            if !Self::validate_session_health(&session) {
38                eprintln!("Session {} appears to be dead.", session.id);
39                eprintln!("The process (PID {}) is no longer running.", session.pid);
40                eprintln!("");
41                eprintln!("Would you like to:");
42                eprintln!("  1. Clean up the dead session");
43                eprintln!("  2. Try to attach anyway (will likely fail)");
44                eprintln!("");
45
46                // For now, attempt cleanup and return error
47                eprintln!("Cleaning up dead session...");
48                let _ = Session::cleanup(&session.id);
49                let _ = SessionHistory::record_session_crashed(&session);
50
51                return Err(NdsError::SessionNotFound(format!(
52                    "Session {} was dead and has been cleaned up. Create a new session with 'nds new'.",
53                    session.id
54                )));
55            }
56
57            if session.attached {
58                // Session appears to be attached, but allow override
59                // This handles cases where terminal closed without proper detach
60                eprintln!(
61                    "Warning: Session {} appears to be already attached.",
62                    current_session_id
63                );
64                eprintln!("Attempting to attach anyway (previous connection may have been lost).");
65
66                // Force mark as detached first to clean up stale state
67                session.attached = false;
68                session.save()?;
69            }
70
71            // Mark as attached
72            session.mark_attached()?;
73
74            // Record attach event in history
75            let _ = SessionHistory::record_session_attached(&session);
76
77            // Attach to the session with better error handling
78            let switch_to = match PtyProcess::attach_to_session(&session) {
79                Ok(result) => result,
80                Err(e) => {
81                    // If we get a broken pipe or connection refused, the session is dead
82                    if matches!(e, NdsError::Io(ref io_err) if
83                        io_err.kind() == std::io::ErrorKind::BrokenPipe ||
84                        io_err.kind() == std::io::ErrorKind::ConnectionRefused)
85                    {
86                        eprintln!(
87                            "\nSession {} is dead (broken pipe/connection refused).",
88                            session.id
89                        );
90                        eprintln!("Cleaning up dead session...");
91
92                        // Mark as detached and cleanup
93                        let _ = session.mark_detached();
94                        let _ = Session::cleanup(&session.id);
95                        let _ = SessionHistory::record_session_crashed(&session);
96
97                        return Err(NdsError::SessionNotFound(format!(
98                            "Session {} was dead and has been cleaned up. Create a new session with 'nds new'.",
99                            session.id
100                        )));
101                    }
102                    return Err(e);
103                }
104            };
105
106            // Mark as detached when done
107            let _ = session.mark_detached();
108
109            // Record detach event in history
110            let _ = SessionHistory::record_session_detached(&session);
111
112            // If switching to another session, continue the loop
113            if let Some(new_session_id) = switch_to {
114                // Update current session ID and continue
115                current_session_id = new_session_id;
116            } else {
117                // Normal detach
118                return Ok(());
119            }
120        }
121    }
122
123    pub fn list_sessions() -> Result<Vec<Session>> {
124        Session::list_all()
125    }
126
127    pub fn kill_session(session_id: &str) -> Result<()> {
128        // Load session for history recording
129        if let Ok(session) = Session::load(session_id) {
130            // Record kill event in history
131            let _ = SessionHistory::record_session_killed(&session);
132        }
133
134        PtyProcess::kill_session(session_id)
135    }
136
137    pub fn get_session(session_id: &str) -> Result<Session> {
138        Session::load(session_id)
139    }
140
141    pub fn rename_session(session_id: &str, new_name: &str) -> Result<()> {
142        let mut session = Session::load(session_id)?;
143        let old_name = session.name.clone();
144
145        session.name = if new_name.trim().is_empty() {
146            None
147        } else {
148            Some(new_name.to_string())
149        };
150
151        // Record rename event in history
152        if let Some(ref name) = session.name {
153            let _ = SessionHistory::record_session_renamed(&session, old_name, name.clone());
154        }
155
156        session.save()
157    }
158
159    pub fn cleanup_dead_sessions() -> Result<()> {
160        let sessions = Session::list_all()?;
161        let mut cleaned = 0;
162
163        for session in sessions {
164            if !Self::validate_session_health(&session) {
165                // Record crash event in history before cleanup
166                let _ = SessionHistory::record_session_crashed(&session);
167                Session::cleanup(&session.id)?;
168                cleaned += 1;
169                println!("Cleaned up dead session: {}", session.display_name());
170            }
171        }
172
173        if cleaned > 0 {
174            println!("Cleaned up {} dead session(s)", cleaned);
175        } else {
176            println!("No dead sessions found");
177        }
178
179        Ok(())
180    }
181
182    /// Validate that a session is healthy and can be attached to
183    fn validate_session_health(session: &Session) -> bool {
184        // First check if the process is alive
185        if !Session::is_process_alive(session.pid) {
186            return false;
187        }
188
189        // Check if the socket file exists
190        if !session.socket_path.exists() {
191            return false;
192        }
193
194        // Try to connect to the socket to verify it's responsive
195        // We use a very short timeout to avoid hanging
196        use std::os::unix::net::UnixStream;
197        use std::time::Duration;
198
199        match UnixStream::connect(&session.socket_path) {
200            Ok(socket) => {
201                // Set a short timeout for the test
202                let _ = socket.set_read_timeout(Some(Duration::from_millis(100)));
203                let _ = socket.set_write_timeout(Some(Duration::from_millis(100)));
204
205                // Socket is connectable, session is likely healthy
206                drop(socket);
207                true
208            }
209            Err(_) => {
210                // Can't connect to socket, session is likely dead
211                false
212            }
213        }
214    }
215}
216
217// Helper for pretty-printing sessions
218pub struct SessionDisplay<'a> {
219    pub session: &'a Session,
220    pub is_current: bool,
221}
222
223impl<'a> SessionDisplay<'a> {
224    pub fn new(session: &'a Session) -> Self {
225        SessionDisplay {
226            session,
227            is_current: false,
228        }
229    }
230
231    pub fn with_current(session: &'a Session, is_current: bool) -> Self {
232        SessionDisplay {
233            session,
234            is_current,
235        }
236    }
237
238    fn format_duration(&self) -> String {
239        let now = Utc::now();
240        let duration = now - self.session.created_at;
241
242        if duration.num_days() > 0 {
243            format!("{}d", duration.num_days())
244        } else if duration.num_hours() > 0 {
245            format!("{}h", duration.num_hours())
246        } else if duration.num_minutes() > 0 {
247            format!("{}m", duration.num_minutes())
248        } else {
249            format!("{}s", duration.num_seconds())
250        }
251    }
252
253    fn format_time(&self) -> String {
254        let now = Local::now();
255        let local_time: DateTime<Local> = self.session.created_at.into();
256        let duration = now.signed_duration_since(local_time);
257
258        if duration.num_days() > 0 {
259            format!(
260                "{}d, {:02}:{:02}",
261                duration.num_days(),
262                local_time.hour(),
263                local_time.minute()
264            )
265        } else {
266            local_time.format("%H:%M:%S").to_string()
267        }
268    }
269}
270
271impl<'a> fmt::Display for SessionDisplay<'a> {
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        // Check if we're inside an nds session - use simpler output if so
274        let in_nds_session = std::env::var("NDS_SESSION_ID").is_ok();
275
276        if in_nds_session {
277            // Simple format for PTY sessions
278            let client_count = self.session.get_client_count();
279            let status = if self.is_current {
280                "CURRENT"
281            } else if client_count > 0 {
282                "attached"
283            } else {
284                "detached"
285            };
286
287            write!(
288                f,
289                "{} [{}] - PID {} - {}",
290                self.session.display_name(),
291                &self.session.id[..8],
292                self.session.pid,
293                status
294            )
295        } else {
296            // Full formatted output for normal terminal
297            // Get client count
298            let client_count = self.session.get_client_count();
299
300            // Status icon and color
301            let (icon, status_text) = if self.is_current {
302                (
303                    "★",
304                    format!(
305                        "CURRENT · {} client{}",
306                        client_count,
307                        if client_count == 1 { "" } else { "s" }
308                    ),
309                )
310            } else if client_count > 0 {
311                (
312                    "●",
313                    format!(
314                        "{} client{}",
315                        client_count,
316                        if client_count == 1 { "" } else { "s" }
317                    ),
318                )
319            } else {
320                ("○", "detached".to_string())
321            };
322
323            // Truncate working dir if too long
324            let mut working_dir = self.session.working_dir.clone();
325            if working_dir.len() > 30 {
326                // Show last 27 chars with ellipsis
327                working_dir = format!(
328                    "...{}",
329                    &self.session.working_dir[self.session.working_dir.len() - 27..]
330                );
331            }
332
333            // Format with sleek layout including all info
334            write!(
335                f,
336                " {} {:<25} │ PID {:<6} │ {:<8} │ {:<8} │ {:<30} │ {}",
337                icon,
338                self.session.display_name(),
339                self.session.pid,
340                self.format_duration(),
341                self.format_time(),
342                working_dir,
343                status_text
344            )
345        }
346    }
347}
348
349pub struct SessionTable {
350    sessions: Vec<Session>,
351    current_session_id: Option<String>,
352}
353
354impl SessionTable {
355    pub fn new(sessions: Vec<Session>) -> Self {
356        // Check if we're currently attached to a session
357        let current_session_id = std::env::var("NDS_SESSION_ID").ok();
358        SessionTable {
359            sessions,
360            current_session_id,
361        }
362    }
363
364    pub fn print(&self) {
365        if self.sessions.is_empty() {
366            println!("No active sessions");
367            return;
368        }
369
370        // Check if we're inside an nds session - use simpler output to avoid PTY corruption
371        let in_nds_session = std::env::var("NDS_SESSION_ID").is_ok();
372
373        if in_nds_session {
374            // Simple output for use within PTY sessions to avoid display corruption
375            self.print_simple();
376        } else {
377            // Full formatted output for normal terminal
378            self.print_formatted();
379        }
380    }
381
382    fn print_simple(&self) {
383        // Simple, PTY-friendly output without complex formatting
384        println!("Active sessions:");
385        println!();
386
387        for session in &self.sessions {
388            let is_current = self.current_session_id.as_ref() == Some(&session.id);
389            let client_count = session.get_client_count();
390
391            // Simple format without complex columns or ANSI codes
392            let status = if is_current {
393                "[CURRENT]"
394            } else if client_count > 0 {
395                &format!("[{} clients]", client_count)
396            } else {
397                "[detached]"
398            };
399
400            println!(
401                "  {} {} - PID {} {}",
402                session.display_name(),
403                &session.id[..8],
404                session.pid,
405                status
406            );
407        }
408
409        println!();
410        println!("Total: {} sessions", self.sessions.len());
411    }
412
413    fn print_formatted(&self) {
414        // Sleek header
415        println!("SESSIONS\n");
416
417        // Print sessions with full formatting
418        for session in &self.sessions {
419            let is_current = self.current_session_id.as_ref() == Some(&session.id);
420            println!("{}", SessionDisplay::with_current(session, is_current));
421        }
422
423        // Footer
424        println!("\n{} sessions", self.sessions.len());
425    }
426}