696 lines
24 KiB
Rust
696 lines
24 KiB
Rust
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
//! 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<TerminalTab>,
|
|
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<String>, cols: u16, rows: u16) -> Result<u32> {
|
|
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<usize> = 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<usize> = 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());
|
|
}
|
|
}
|