detached_shell/
manager.rs1use 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 let session_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
19
20 let session = PtyProcess::spawn_new_detached_with_name(&session_id, name)?;
22
23 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 let mut session = Session::load(¤t_session_id)?;
35
36 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 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 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 session.attached = false;
68 session.save()?;
69 }
70
71 session.mark_attached()?;
73
74 let _ = SessionHistory::record_session_attached(&session);
76
77 let switch_to = match PtyProcess::attach_to_session(&session) {
79 Ok(result) => result,
80 Err(e) => {
81 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 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 let _ = session.mark_detached();
108
109 let _ = SessionHistory::record_session_detached(&session);
111
112 if let Some(new_session_id) = switch_to {
114 current_session_id = new_session_id;
116 } else {
117 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 if let Ok(session) = Session::load(session_id) {
130 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 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 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 fn validate_session_health(session: &Session) -> bool {
184 if !Session::is_process_alive(session.pid) {
186 return false;
187 }
188
189 if !session.socket_path.exists() {
191 return false;
192 }
193
194 use std::os::unix::net::UnixStream;
197 use std::time::Duration;
198
199 match UnixStream::connect(&session.socket_path) {
200 Ok(socket) => {
201 let _ = socket.set_read_timeout(Some(Duration::from_millis(100)));
203 let _ = socket.set_write_timeout(Some(Duration::from_millis(100)));
204
205 drop(socket);
207 true
208 }
209 Err(_) => {
210 false
212 }
213 }
214 }
215}
216
217pub 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 let in_nds_session = std::env::var("NDS_SESSION_ID").is_ok();
275
276 if in_nds_session {
277 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 let client_count = self.session.get_client_count();
299
300 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 let mut working_dir = self.session.working_dir.clone();
325 if working_dir.len() > 30 {
326 working_dir = format!(
328 "...{}",
329 &self.session.working_dir[self.session.working_dir.len() - 27..]
330 );
331 }
332
333 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 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 let in_nds_session = std::env::var("NDS_SESSION_ID").is_ok();
372
373 if in_nds_session {
374 self.print_simple();
376 } else {
377 self.print_formatted();
379 }
380 }
381
382 fn print_simple(&self) {
383 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 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 println!("SESSIONS\n");
416
417 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 println!("\n{} sessions", self.sessions.len());
425 }
426}