use super::{Tab, TabId};
use crate::config::Config;
use crate::profile::Profile;
use anyhow::Result;
use std::sync::Arc;
use tokio::runtime::Runtime;
pub struct TabManager {
pub(super) tabs: Vec<Tab>,
pub(super) active_tab_id: Option<TabId>,
next_tab_id: TabId,
}
impl TabManager {
pub fn new() -> Self {
Self {
tabs: Vec::new(),
active_tab_id: None,
next_tab_id: 1,
}
}
pub(super) fn set_active_tab(&mut self, id: Option<TabId>) {
use std::sync::atomic::Ordering;
if let Some(old_id) = self.active_tab_id
&& let Some(old_tab) = self.tabs.iter().find(|t| t.id == old_id)
{
old_tab.is_active.store(false, Ordering::Relaxed);
if let Some(ref pm) = old_tab.pane_manager {
for pane in pm.all_panes() {
pane.is_active.store(false, Ordering::Relaxed);
}
}
}
if let Some(new_id) = id
&& let Some(new_tab) = self.tabs.iter().find(|t| t.id == new_id)
{
new_tab.is_active.store(true, Ordering::Relaxed);
if let Some(ref pm) = new_tab.pane_manager {
for pane in pm.all_panes() {
pane.is_active.store(true, Ordering::Relaxed);
}
}
}
self.active_tab_id = id;
}
pub fn new_tab(
&mut self,
config: &Config,
runtime: Arc<Runtime>,
inherit_cwd_from_active: bool,
grid_size: Option<(usize, usize)>,
) -> Result<TabId> {
let working_dir = if inherit_cwd_from_active {
self.active_tab().and_then(|tab| tab.get_cwd())
} else {
None
};
let id = self.next_tab_id;
self.next_tab_id += 1;
let tab_number = self.tabs.len() + 1;
let tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
self.tabs.push(tab);
self.set_active_tab(Some(id));
log::info!("Created new tab {} (total: {})", id, self.tabs.len());
Ok(id)
}
pub fn new_tab_with_cwd(
&mut self,
config: &Config,
runtime: Arc<Runtime>,
working_dir: Option<String>,
grid_size: Option<(usize, usize)>,
) -> Result<TabId> {
let id = self.next_tab_id;
self.next_tab_id += 1;
let tab_number = self.tabs.len() + 1;
let tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
self.tabs.push(tab);
self.set_active_tab(Some(id));
log::info!(
"Created new tab {} with cwd (total: {})",
id,
self.tabs.len()
);
Ok(id)
}
pub fn new_tab_from_profile(
&mut self,
config: &Config,
runtime: Arc<Runtime>,
profile: &Profile,
grid_size: Option<(usize, usize)>,
) -> Result<TabId> {
let id = self.next_tab_id;
self.next_tab_id += 1;
let tab = Tab::new_from_profile(id, config, runtime, profile, grid_size)?;
self.tabs.push(tab);
self.set_active_tab(Some(id));
log::info!(
"Created new tab {} from profile '{}' (total: {})",
id,
profile.name,
self.tabs.len()
);
Ok(id)
}
pub fn close_tab(&mut self, id: TabId) -> bool {
let index = self.tabs.iter().position(|t| t.id == id);
if let Some(idx) = index {
log::info!("Closing tab {} (index {})", id, idx);
self.tabs.remove(idx);
if self.active_tab_id == Some(id) {
let new_id = if self.tabs.is_empty() {
None
} else {
let new_idx = idx.min(self.tabs.len().saturating_sub(1));
Some(self.tabs[new_idx].id)
};
self.set_active_tab(new_id);
}
self.renumber_default_tabs();
}
self.tabs.is_empty()
}
pub fn remove_tab(&mut self, id: TabId) -> Option<(Tab, bool)> {
let idx = self.tabs.iter().position(|t| t.id == id)?;
log::info!("Removing tab {} (index {}) without dropping", id, idx);
let tab = self.tabs.remove(idx);
if self.active_tab_id == Some(id) {
let new_id = if self.tabs.is_empty() {
None
} else {
let new_idx = idx.min(self.tabs.len().saturating_sub(1));
Some(self.tabs[new_idx].id)
};
self.set_active_tab(new_id);
}
self.renumber_default_tabs();
let is_empty = self.tabs.is_empty();
Some((tab, is_empty))
}
pub fn insert_tab_at(&mut self, tab: Tab, index: usize) {
let clamped = index.min(self.tabs.len());
let id = tab.id;
self.tabs.insert(clamped, tab);
self.set_active_tab(Some(id));
self.renumber_default_tabs();
log::info!(
"Inserted tab {} at index {} (total: {})",
id,
clamped,
self.tabs.len()
);
}
pub(super) fn renumber_default_tabs(&mut self) {
for (idx, tab) in self.tabs.iter_mut().enumerate() {
tab.set_default_title(idx + 1);
}
}
pub fn active_tab(&self) -> Option<&Tab> {
self.active_tab_id
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
}
pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
let active_id = self.active_tab_id;
active_id.and_then(move |id| self.tabs.iter_mut().find(|t| t.id == id))
}
pub fn tab_count(&self) -> usize {
self.tabs.len()
}
pub fn visible_tab_count(&self) -> usize {
self.tabs.iter().filter(|t| !t.is_hidden).count()
}
pub fn visible_tabs(&self) -> Vec<&Tab> {
self.tabs.iter().filter(|t| !t.is_hidden).collect()
}
pub fn has_multiple_tabs(&self) -> bool {
self.tabs.len() > 1
}
pub fn active_tab_id(&self) -> Option<TabId> {
self.active_tab_id
}
pub fn tabs(&self) -> &[Tab] {
&self.tabs
}
pub fn tabs_mut(&mut self) -> &mut [Tab] {
&mut self.tabs
}
pub fn drain_tabs(&mut self) -> Vec<Tab> {
self.set_active_tab(None);
std::mem::take(&mut self.tabs)
}
pub fn get_tab(&self, id: TabId) -> Option<&Tab> {
self.tabs.iter().find(|t| t.id == id)
}
pub fn get_tab_mut(&mut self, id: TabId) -> Option<&mut Tab> {
self.tabs.iter_mut().find(|t| t.id == id)
}
pub fn mark_activity(&mut self, tab_id: TabId) {
if Some(tab_id) != self.active_tab_id
&& let Some(tab) = self.get_tab_mut(tab_id)
{
tab.activity.has_activity = true;
}
}
pub fn update_all_titles(
&mut self,
title_mode: par_term_config::TabTitleMode,
remote_format: par_term_config::RemoteTabTitleFormat,
remote_osc_priority: bool,
) {
for tab in &mut self.tabs {
tab.update_title(title_mode, remote_format, remote_osc_priority);
}
}
pub fn duplicate_active_tab(
&mut self,
config: &Config,
runtime: Arc<Runtime>,
grid_size: Option<(usize, usize)>,
) -> Result<Option<TabId>> {
if let Some(tab_id) = self.active_tab_id {
self.duplicate_tab_by_id(tab_id, config, runtime, grid_size)
} else {
Ok(None)
}
}
pub fn duplicate_tab_by_id(
&mut self,
source_tab_id: TabId,
config: &Config,
runtime: Arc<Runtime>,
grid_size: Option<(usize, usize)>,
) -> Result<Option<TabId>> {
let source_idx = self.tabs.iter().position(|t| t.id == source_tab_id);
let source_idx = match source_idx {
Some(idx) => idx,
None => return Ok(None),
};
let working_dir = self.tabs[source_idx].get_cwd();
let custom_color = self.tabs[source_idx].custom_color;
let custom_icon = self.tabs[source_idx].custom_icon.clone();
let id = self.next_tab_id;
self.next_tab_id += 1;
let tab_number = self.tabs.len() + 1;
let mut tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
if let Some(color) = custom_color {
tab.set_custom_color(color);
}
tab.custom_icon = custom_icon;
self.tabs.insert(source_idx + 1, tab);
self.set_active_tab(Some(id));
Ok(Some(id))
}
pub fn new_tab_from_pane(
&mut self,
pane: crate::pane::Pane,
config: &Config,
runtime: Arc<Runtime>,
insert_after: Option<TabId>,
) -> TabId {
let id = self.next_tab_id;
self.next_tab_id += 1;
let tab_number = self.tabs.len() + 1;
let tab = crate::tab::Tab::new_from_pane(id, pane, config, runtime, tab_number);
let insert_idx = insert_after
.and_then(|after_id| self.tabs.iter().position(|t| t.id == after_id))
.map(|idx| idx + 1)
.unwrap_or(self.tabs.len());
self.tabs.insert(insert_idx, tab);
self.set_active_tab(Some(id));
self.renumber_default_tabs();
id
}
pub fn active_tab_index(&self) -> Option<usize> {
self.active_tab_id
.and_then(|id| self.tabs.iter().position(|t| t.id == id))
}
pub fn cleanup_dead_tabs(&mut self) {
let dead_tabs: Vec<TabId> = self
.tabs
.iter()
.filter(|t| !t.is_running())
.map(|t| t.id)
.collect();
for id in dead_tabs {
log::info!("Cleaning up dead tab {}", id);
self.close_tab(id);
}
}
}
impl Default for TabManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tokio::runtime::Builder;
fn test_runtime() -> Arc<tokio::runtime::Runtime> {
Arc::new(
Builder::new_current_thread()
.enable_all()
.build()
.expect("build test runtime"),
)
}
fn manager_with_ids(ids: &[TabId]) -> TabManager {
let mut mgr = TabManager::new();
for &id in ids {
let tab_number = mgr.tabs.len() + 1;
mgr.tabs.push(Tab::new_stub(id, tab_number));
mgr.next_tab_id = mgr.next_tab_id.max(id + 1);
}
if let Some(last) = ids.last() {
mgr.active_tab_id = Some(*last);
}
mgr
}
#[test]
fn move_tab_to_index_forward() {
let mut mgr = manager_with_ids(&[1, 2, 3, 4]);
assert!(mgr.move_tab_to_index(1, 2));
let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
assert_eq!(ids, vec![2, 3, 1, 4]);
}
#[test]
fn move_tab_to_index_backward() {
let mut mgr = manager_with_ids(&[1, 2, 3, 4]);
assert!(mgr.move_tab_to_index(3, 0));
let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
assert_eq!(ids, vec![3, 1, 2, 4]);
}
#[test]
fn move_tab_to_index_same_position() {
let mut mgr = manager_with_ids(&[1, 2, 3]);
assert!(!mgr.move_tab_to_index(2, 1));
let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn move_tab_to_index_out_of_bounds_clamped() {
let mut mgr = manager_with_ids(&[1, 2, 3]);
assert!(mgr.move_tab_to_index(1, 100));
let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
assert_eq!(ids, vec![2, 3, 1]);
}
#[test]
fn move_tab_to_index_invalid_id() {
let mut mgr = manager_with_ids(&[1, 2, 3]);
assert!(!mgr.move_tab_to_index(99, 0));
let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn move_tab_to_index_to_end() {
let mut mgr = manager_with_ids(&[1, 2, 3]);
assert!(mgr.move_tab_to_index(1, 2));
let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
assert_eq!(ids, vec![2, 3, 1]);
}
#[test]
fn move_tab_to_index_to_start() {
let mut mgr = manager_with_ids(&[1, 2, 3]);
assert!(mgr.move_tab_to_index(3, 0));
let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
assert_eq!(ids, vec![3, 1, 2]);
}
#[test]
#[ignore = "requires PTY spawn"]
fn remove_insert_round_trip_preserves_tab_fields() {
let mut mgr = TabManager::new();
let config = Config::default();
let runtime = test_runtime();
let _ = mgr
.new_tab(&config, Arc::clone(&runtime), false, Some((80, 24)))
.expect("create tab 1");
let id = mgr
.new_tab(&config, Arc::clone(&runtime), false, Some((80, 24)))
.expect("create tab 2");
{
let tab = mgr.get_tab_mut(id).expect("target tab exists");
tab.set_title("my-tab");
tab.user_named = true;
tab.set_custom_color([10, 20, 30]);
tab.custom_icon = Some("\u{f120}".to_string());
}
let snapshot = {
let tab = mgr.get_tab(id).expect("target tab exists");
(
tab.id,
tab.title.clone(),
tab.has_default_title,
tab.user_named,
tab.custom_color,
tab.custom_icon.clone(),
)
};
let (live_tab, is_empty) = mgr.remove_tab(id).expect("remove returns Some");
assert!(!is_empty, "manager should still have tab 1");
mgr.insert_tab_at(live_tab, 1);
let after = mgr.get_tab(id).expect("tab still present after round-trip");
assert_eq!(after.id, snapshot.0, "id mismatch");
assert_eq!(after.title, snapshot.1, "title mismatch");
assert_eq!(
after.has_default_title, snapshot.2,
"has_default_title mismatch"
);
assert_eq!(after.user_named, snapshot.3, "user_named mismatch");
assert_eq!(after.custom_color, snapshot.4, "custom_color mismatch");
assert_eq!(after.custom_icon, snapshot.5, "custom_icon mismatch");
}
}