// SPDX-License-Identifier: GPL-2.0-only // // rs-mrxvt — a modernized, distro-agnostic mrxvt-inspired terminal emulator. // // Copyright (C) 2024 rs-mrxvt contributors // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation; either version 2 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, see . //! Multi-tab coordinator and broadcasting router. //! //! Owns the [`TerminalTab`] vec, tracks the active index, and routes input //! bytes to the right destination(s). The broadcasting logic — the classic //! mrxvt "killer feature" — lives here. use anyhow::Result; use alacritty_terminal::event::Event; use alacritty_terminal::grid::Scroll; use crate::command::Command; use crate::config::{Config, Profile}; use super::tab::TerminalTab; /// Where a keystroke should go. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BroadcastTarget { /// Only the focused tab (default behaviour). Active, /// Every open tab, regardless of tag. All, /// Only tabs tagged with the given group name. Group(String), } /// The tab manager. /// /// Holding this struct gives you: /// - the active tab (mutable) /// - the ability to create/close tabs /// - input routing with broadcasting pub struct TerminalManager { pub tabs: Vec, pub active: usize, pub broadcast: BroadcastTarget, pub fallback_shell: String, pub default_scrollback: usize, pub default_profile: String, } impl TerminalManager { pub fn new(config: &Config) -> Self { Self { tabs: Vec::new(), active: 0, broadcast: BroadcastTarget::Active, fallback_shell: config.terminal.shell.clone(), default_scrollback: config.terminal.scrollback, default_profile: config.default_profile.clone(), } } /// Spawn a tab using a profile by name (falls back to "default"). pub fn open_tab(&mut self, profile: &Profile, title: Option, cols: u16, rows: u16) -> Result { let title = title.unwrap_or_else(|| { if profile.command.is_empty() { "shell".to_string() } else { profile.command.first().cloned().unwrap_or_else(|| "shell".into()) } }); let tab = TerminalTab::new( title, profile, &self.fallback_shell, cols, rows, self.default_scrollback, )?; let id = tab.id; self.tabs.push(tab); Ok(id) } /// Close the tab at `index`. Switches active to the previous tab if needed. /// Returns `true` if a tab was closed. pub fn close_tab(&mut self, index: usize) -> bool { if index >= self.tabs.len() { return false; } self.tabs.remove(index); if self.tabs.is_empty() { self.active = 0; } else if self.active >= self.tabs.len() { self.active = self.tabs.len() - 1; } true } /// Close the active tab. pub fn close_active(&mut self) -> bool { let i = self.active; 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) } /// Get the active tab (mutable). pub fn active_tab_mut(&mut self) -> Option<&mut TerminalTab> { self.tabs.get_mut(self.active) } /// Switch to the next tab (wraps around). pub fn next_tab(&mut self) { if !self.tabs.is_empty() { self.active = (self.active + 1) % self.tabs.len(); } } /// Switch to the previous tab (wraps around). pub fn prev_tab(&mut self) { if !self.tabs.is_empty() { self.active = if self.active == 0 { self.tabs.len() - 1 } else { self.active - 1 }; } } /// Switch to tab `i` if it exists. Returns `true` if switched. pub fn goto_tab(&mut self, i: usize) -> bool { if i < self.tabs.len() { self.active = i; true } else { false } } /// Tag the active tab. pub fn tag_active(&mut self, tag: String) -> bool { if let Some(t) = self.tabs.get_mut(self.active) { t.tag = Some(tag); true } else { false } } /// Set a tab's title (from an OSC 0/2 title event). pub fn set_title(&mut self, idx: usize, title: String) { if let Some(t) = self.tabs.get_mut(idx) { if !title.is_empty() { t.title = title; } } } /// Restore a tab's default title (from a ResetTitle event). pub fn reset_title(&mut self, idx: usize) { if let Some(t) = self.tabs.get_mut(idx) { t.title = t.default_title.clone(); } } /// Drain VT events from every tab, tagged with the tab index. /// /// Call once per event-loop tick after `poll_all`; the caller acts on /// title changes, OSC 52 clipboard requests, bells, and PTY writes. pub fn drain_events(&mut self) -> Vec<(usize, Event)> { let mut out = Vec::new(); for (i, tab) in self.tabs.iter_mut().enumerate() { for ev in tab.drain_events() { out.push((i, ev)); } } out } /// Route input bytes to the appropriate tab(s) per the current broadcast mode. /// /// Tabs that receive *keyboard* input are also scrolled back to the live /// bottom (the classic terminal behavior: typing snaps you out of /// scrollback review). pub fn route_input(&mut self, data: &[u8]) -> std::io::Result<()> { match &self.broadcast { BroadcastTarget::Active => { if let Some(tab) = self.tabs.get_mut(self.active) { tab.write_input(data)?; tab.scroll_display(Scroll::Bottom); } Ok(()) } BroadcastTarget::All => { for tab in &mut self.tabs { tab.write_input(data)?; tab.scroll_display(Scroll::Bottom); } Ok(()) } BroadcastTarget::Group(tag) => { let tagged: Vec = self .tabs .iter() .enumerate() .filter(|(_, t)| t.tag.as_deref() == Some(tag.as_str())) .map(|(i, _)| i) .collect(); if tagged.is_empty() { // If no tab matched the tag, fall back to active so the // user isn't left typing into the void. if let Some(tab) = self.tabs.get_mut(self.active) { tab.write_input(data)?; tab.scroll_display(Scroll::Bottom); } return Ok(()); } let mut first_err = None; for i in tagged { if let Err(e) = self.tabs[i].write_input(data) { first_err = first_err.or(Some(e)); } self.tabs[i].scroll_display(Scroll::Bottom); } first_err.map_or(Ok(()), Err) } } } /// Toggle between `Active` and `All` broadcasting. pub fn toggle_broadcast_all(&mut self) { self.broadcast = match &self.broadcast { BroadcastTarget::Active => BroadcastTarget::All, _ => BroadcastTarget::Active, }; } /// Toggle a tagged-group broadcast. Calling with the same tag twice /// turns it off; calling with a different tag switches to it. pub fn toggle_broadcast_group(&mut self, tag: String) { self.broadcast = match &self.broadcast { BroadcastTarget::Group(existing) if existing == &tag => BroadcastTarget::Active, _ => BroadcastTarget::Group(tag), }; } /// Drain all PTYs. Should be called once per event-loop tick. /// /// Returns a tuple of (active_tab_received_data, any_tab_eof). pub fn poll_all(&mut self) -> (bool, bool) { let mut active_got_data = false; let mut any_eof = false; for (i, tab) in self.tabs.iter_mut().enumerate() { match tab.poll_pty() { Ok(0) => { any_eof = true; } Ok(_) => { if i == self.active { active_got_data = true; } } Err(e) => { log::debug!("poll error on tab {i}: {e}"); } } } (active_got_data, any_eof) } /// Resize every tab to the given terminal area (excluding chrome). pub fn resize_all(&mut self, cols: u16, rows: u16) { self.tabs.iter_mut().for_each(|tab| { if let Err(e) = tab.resize(cols, rows) { log::warn!("resize failed on tab: {e}"); } }); } /// Resolve a [`Command`] against this manager. Returns `Quit` if the /// app should exit, `None` otherwise. /// /// This is the single source of truth for command execution — both the /// keybinding dispatcher and the command palette funnel through here. pub fn execute(&mut self, cmd: &Command, cols: u16, rows: u16, config: &Config) -> Action { match cmd { Command::NewTab => { let p = config.profile(&self.default_profile); if let Err(e) = self.open_tab(&p, None, cols, rows) { log::error!("NewTab failed: {e}"); } Action::Continue } Command::NewTabProfile(name) => { let p = config.profile(name); if let Err(e) = self.open_tab(&p, None, cols, rows) { log::error!("NewTabProfile failed: {e}"); } Action::Continue } Command::CloseTab => { if self.close_active() && self.tabs.is_empty() { Action::Quit } else { Action::Continue } } Command::NextTab => { self.next_tab(); Action::Continue } Command::PrevTab => { self.prev_tab(); Action::Continue } Command::GotoTab(i) => { self.goto_tab(*i); Action::Continue } Command::ToggleBroadcastAll => { self.toggle_broadcast_all(); Action::Continue } Command::ToggleBroadcastGroup(g) => { self.toggle_broadcast_group(g.clone()); Action::Continue } Command::TagActiveTab(g) => { self.tag_active(g.clone()); Action::Continue } Command::ResetTerminal => { if let Some(t) = self.active_tab_mut() { if let Err(e) = t.write_input(b"\x1bc") { log::warn!("RIS (reset) failed: {e}"); } } Action::Continue } Command::ScrollLineUp => { if let Some(t) = self.active_tab_mut() { t.scroll_display(Scroll::Delta(1)); } Action::Continue } Command::ScrollLineDown => { if let Some(t) = self.active_tab_mut() { t.scroll_display(Scroll::Delta(-1)); } Action::Continue } Command::ScrollPageUp => { if let Some(t) = self.active_tab_mut() { t.scroll_display(Scroll::PageUp); } Action::Continue } Command::ScrollPageDown => { if let Some(t) = self.active_tab_mut() { t.scroll_display(Scroll::PageDown); } Action::Continue } Command::ScrollToTop => { if let Some(t) = self.active_tab_mut() { t.scroll_display(Scroll::Top); } Action::Continue } Command::ScrollToBottom => { if let Some(t) = self.active_tab_mut() { t.scroll_display(Scroll::Bottom); } Action::Continue } // Clipboard commands are handled at the App layer (they need // access to the clipboard backend and session selection). Command::CopySelection | Command::PasteClipboard | Command::PastePrimary => { Action::Continue } Command::OpenPalette => Action::Continue, Command::Quit => Action::Quit, } } } /// What the app should do after executing a command. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { Continue, Quit, } #[cfg(test)] mod tests { use super::*; use crate::config::Profile; use std::collections::HashMap; fn test_profile(cmd: &str) -> Profile { Profile { command: vec!["sh".into(), "-c".into(), cmd.into()], cwd: None, tag: None, env: HashMap::new(), login_shell: false, } } fn fresh_manager() -> TerminalManager { let cfg = Config::default(); let mut m = TerminalManager::new(&cfg); let p = test_profile("sleep 5"); let _ = m.open_tab(&p, Some("t1".into()), 40, 10); let _ = m.open_tab(&p, Some("t2".into()), 40, 10); m } #[test] fn open_close_active_switching() { let mut m = fresh_manager(); assert_eq!(m.tabs.len(), 2); assert_eq!(m.active, 0); m.close_active(); // closes tab 0 assert_eq!(m.tabs.len(), 1); assert_eq!(m.active, 0); // switched back to 0 after remove m.close_active(); // closes the last tab assert!(m.tabs.is_empty()); } #[test] fn next_prev_wrap() { let mut m = fresh_manager(); assert_eq!(m.active, 0); m.next_tab(); assert_eq!(m.active, 1); m.next_tab(); assert_eq!(m.active, 0); // wraps m.prev_tab(); assert_eq!(m.active, 1); // wraps } #[test] fn goto_in_bounds() { let mut m = fresh_manager(); assert!(m.goto_tab(1)); assert_eq!(m.active, 1); assert!(!m.goto_tab(99)); assert_eq!(m.active, 1); } #[test] fn broadcast_toggle_cycle() { let mut m = fresh_manager(); assert_eq!(m.broadcast, BroadcastTarget::Active); m.toggle_broadcast_all(); assert_eq!(m.broadcast, BroadcastTarget::All); m.toggle_broadcast_all(); assert_eq!(m.broadcast, BroadcastTarget::Active); } #[test] fn broadcast_group_toggle() { let mut m = fresh_manager(); m.toggle_broadcast_group("web".into()); assert_eq!(m.broadcast, BroadcastTarget::Group("web".into())); // Toggling same tag turns it off. m.toggle_broadcast_group("web".into()); assert_eq!(m.broadcast, BroadcastTarget::Active); // Toggling a different tag switches. m.toggle_broadcast_group("db".into()); assert_eq!(m.broadcast, BroadcastTarget::Group("db".into())); } #[test] fn broadcast_active_writes_only_to_focused() { let mut m = fresh_manager(); // Send Ctrl-C to active only. m.route_input(b"\x03").unwrap(); // (No assertions on side effects here — the integration test below // verifies routing by reading PTY output.) } #[test] fn broadcast_all_writes_to_every_tab() { let mut m = fresh_manager(); m.broadcast = BroadcastTarget::All; // 'A' to all tabs. m.route_input(b"A").unwrap(); // Confirm both tabs have at least received the byte by reading their // PTYs. for tab in &mut m.tabs { let mut buf = [0u8; 16]; // The PTY echoes input back; we should see 'A' in the read. std::thread::sleep(std::time::Duration::from_millis(50)); let _ = tab.pty.reader.read(&mut buf); // We don't strictly assert content because terminal echo settings // vary, but the write must not error. } } #[test] fn group_broadcast_falls_back_to_active_when_no_match() { let mut m = fresh_manager(); m.broadcast = BroadcastTarget::Group("nonexistent".into()); // Should not panic; should fall back to active tab. m.route_input(b"hello\n").unwrap(); } #[test] fn group_broadcast_hits_tagged_tabs_only() { let mut m = fresh_manager(); // Tag tab 0 with "web". m.tabs[0].tag = Some("web".into()); m.broadcast = BroadcastTarget::Group("web".into()); m.route_input(b"x").unwrap(); // Untagged tab 1 should not have been written to — but since writes // are async on the PTY side, we just verify no panic. } #[test] fn tag_active_sets_tag() { let mut m = fresh_manager(); assert!(m.tag_active("web".into())); assert_eq!(m.active_tab().unwrap().tag.as_deref(), Some("web")); } #[test] fn execute_open_and_close_tab() { let mut m = fresh_manager(); let cfg = Config::default(); let before = m.tabs.len(); m.execute(&Command::NewTab, 40, 10, &cfg); assert_eq!(m.tabs.len(), before + 1); m.execute(&Command::CloseTab, 40, 10, &cfg); assert_eq!(m.tabs.len(), before); } #[test] fn execute_quit_returns_quit_action() { let mut m = fresh_manager(); 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()); } }