diff --git a/rs-mrxvt/.gitignore b/rs-mrxvt/.gitignore new file mode 100755 index 0000000..836bea3 --- /dev/null +++ b/rs-mrxvt/.gitignore @@ -0,0 +1,26 @@ +# Build artifacts +/target +**/*.rs.bk + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Distribution artifacts +*.deb +*.rpm +*.tar.gz +*.tar.xz +*.dsc +*.changes +*.buildinfo + +# Logs +*.log diff --git a/src/app.rs b/src/app.rs index 7e60c73..38b2b0a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -378,7 +378,7 @@ impl App { let poll_ms = 50; // 20 Hz input poll; PTY drain is per-tick. while !self.quit { // 1. Drain PTYs. - let (_active_changed, any_eof) = self.manager.poll_all(); + let (_active_changed, _any_eof) = self.manager.poll_all(); // 2. Poll for input. while let Some(ev) = renderer.poll_event(poll_ms)? { @@ -390,14 +390,12 @@ impl App { } } - // 3. Handle EOF on a tab — close it. - if any_eof { - // Heuristic: close active tab if its child has exited. - // For the MVP we don't track which tab EOF'd; closing the - // active one matches user expectation most of the time. - // (A more robust approach tracks EOF per-tab.) - // We avoid closing if there's still data being produced. - // For now: don't auto-close; the user can press Ctrl+Shift+W. + // 3. Close tabs whose child process has exited. If the last tab + // closed, quit the app — classic terminal behaviour: + // `exit` in the last tab → app quits + // `exit` in any other tab → only that tab closes + if self.manager.close_dead_tabs() { + self.quit = true; } // 4. Render. diff --git a/src/terminal/manager.rs b/src/terminal/manager.rs index 803de80..b344521 100644 --- a/src/terminal/manager.rs +++ b/src/terminal/manager.rs @@ -112,6 +112,35 @@ impl TerminalManager { self.close_tab(i) } + /// Close every tab whose child process has exited (`eof_seen == true`). + /// + /// Called once per event-loop tick after `poll_all`. Dead tabs are removed + /// and the active index is adjusted to stay in bounds (via `close_tab`). + /// + /// Returns `true` when no tabs remain — the caller should quit the app. + /// This gives the classic terminal behaviour: + /// - `exit` in the **last** tab → app quits. + /// - `exit` in **any other** tab → only that tab closes. + pub fn close_dead_tabs(&mut self) -> bool { + // Collect indices of dead tabs, then remove in reverse order so + // earlier removals don't shift the indices we haven't reached yet. + let dead: Vec = self + .tabs + .iter() + .enumerate() + .filter(|(_, t)| t.eof_seen) + .map(|(i, _)| i) + .collect(); + + for i in dead.into_iter().rev() { + let title = self.tabs.get(i).map(|t| t.title.as_str()).unwrap_or("?"); + log::info!("tab {i} ({title}): child process exited, closing tab"); + self.close_tab(i); + } + + self.tabs.is_empty() + } + /// Get the active tab (immutable). pub fn active_tab(&self) -> Option<&TerminalTab> { self.tabs.get(self.active) @@ -466,4 +495,107 @@ mod tests { let cfg = Config::default(); assert_eq!(m.execute(&Command::Quit, 40, 10, &cfg), Action::Quit); } + + // ─── close_dead_tabs tests ─────────────────────────────────────────── + // + // These test the "exit closes the tab; exit in the last tab quits the + // app" behaviour. Each test spawns real child processes (`true` exits + // immediately, `sleep 10` stays alive) and waits for the reader thread + // to deliver the EOF sentinel through the channel. + + /// Wait for at least one tab to see EOF, with a timeout. This mirrors + /// what the real main loop does: poll_all sets eof_seen, then + /// close_dead_tabs acts on it. + fn wait_for_any_eof(m: &mut TerminalManager, timeout_ms: u64) { + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms); + while std::time::Instant::now() < deadline { + m.poll_all(); + if m.tabs.iter().any(|t| t.eof_seen) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + + #[test] + fn close_dead_tabs_quits_when_last_tab_exits() { + let mut m = TerminalManager::new(&Config::default()); + let p = test_profile("true"); // exits immediately + let _ = m.open_tab(&p, Some("only".into()), 40, 10); + + wait_for_any_eof(&mut m, 2000); + assert!(m.tabs[0].eof_seen, "tab should have observed EOF"); + + let should_quit = m.close_dead_tabs(); + assert!(should_quit, "should quit — last tab closed"); + assert!(m.tabs.is_empty()); + } + + #[test] + fn close_dead_tabs_keeps_alive_tabs_and_only_removes_dead_ones() { + let mut m = TerminalManager::new(&Config::default()); + let dead_p = test_profile("true"); + let alive_p = test_profile("sleep 10"); + let _ = m.open_tab(&dead_p, Some("dead".into()), 40, 10); + let _ = m.open_tab(&alive_p, Some("alive".into()), 40, 10); + + wait_for_any_eof(&mut m, 2000); + + let should_quit = m.close_dead_tabs(); + assert!(!should_quit, "should NOT quit — one tab still alive"); + assert_eq!(m.tabs.len(), 1, "only the dead tab should be removed"); + assert_eq!(m.tabs[0].title, "alive"); + } + + #[test] + fn close_dead_tabs_is_noop_when_all_tabs_alive() { + let mut m = fresh_manager(); // two tabs running "sleep 5" + let should_quit = m.close_dead_tabs(); + assert!(!should_quit); + assert_eq!(m.tabs.len(), 2, "no tabs should be closed"); + } + + #[test] + fn close_dead_tabs_adjusts_active_index_when_active_tab_dies() { + let mut m = TerminalManager::new(&Config::default()); + let alive_p = test_profile("sleep 10"); + let dead_p = test_profile("true"); + let _ = m.open_tab(&alive_p, Some("alive".into()), 40, 10); // index 0 + let _ = m.open_tab(&dead_p, Some("dead".into()), 40, 10); // index 1 + m.active = 1; // user is looking at the tab that's about to die + + wait_for_any_eof(&mut m, 2000); + + let should_quit = m.close_dead_tabs(); + assert!(!should_quit); + assert_eq!(m.tabs.len(), 1); + assert_eq!( + m.active, 0, + "active should fall back to the remaining tab" + ); + assert_eq!(m.tabs[0].title, "alive"); + } + + #[test] + fn close_dead_tabs_handles_multiple_simultaneous_exits() { + let mut m = TerminalManager::new(&Config::default()); + let p = test_profile("true"); + let _ = m.open_tab(&p, Some("a".into()), 40, 10); + let _ = m.open_tab(&p, Some("b".into()), 40, 10); + let _ = m.open_tab(&p, Some("c".into()), 40, 10); + + wait_for_any_eof(&mut m, 2000); + // Wait a bit longer so all three tabs see EOF. + for _ in 0..50 { + m.poll_all(); + if m.tabs.iter().all(|t| t.eof_seen) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let should_quit = m.close_dead_tabs(); + assert!(should_quit, "all tabs exited — should quit"); + assert!(m.tabs.is_empty()); + } }