use std::collections::HashSet;
use std::path::{Path, PathBuf};
use alacritty_terminal::event::Event as TermEvent;
use anyhow::Result;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use unicode_width::UnicodeWidthStr as _;
use super::layout::{self, MIN_GROUP_HEIGHT};
use super::session_layout;
use super::terminal::{GridSize, TabEffect, TabId, TabKind, TerminalTab};
pub struct PaneGroup {
pub tabs: Vec<TerminalTab>,
pub active: usize,
}
impl PaneGroup {
fn new(tab: TerminalTab) -> Self {
Self {
tabs: vec![tab],
active: 0,
}
}
pub fn active_tab(&self) -> Option<&TerminalTab> {
self.tabs.get(self.active)
}
pub fn active_tab_mut(&mut self) -> Option<&mut TerminalTab> {
self.tabs.get_mut(self.active)
}
fn clamp_active(&mut self) {
if self.active >= self.tabs.len() {
self.active = self.tabs.len().saturating_sub(1);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabAddr {
pub group: usize,
pub tab: usize,
}
#[derive(Default)]
pub struct PaneLayout {
pub groups: Vec<PaneGroup>,
pub weights: Vec<u16>,
pub focused: usize,
next_id: TabId,
}
impl PaneLayout {
pub fn is_empty(&self) -> bool {
self.groups.is_empty()
}
pub fn group_count(&self) -> usize {
self.groups.len()
}
fn take_id(&mut self) -> TabId {
self.next_id += 1;
self.next_id
}
fn focused_group(&self) -> Option<&PaneGroup> {
self.groups.get(self.focused)
}
pub fn active_tab(&self) -> Option<&TerminalTab> {
self.focused_group()?.active_tab()
}
pub fn group_tab(&self, index: usize) -> Option<&TerminalTab> {
self.groups.get(index)?.active_tab()
}
pub fn tabs(&self) -> impl Iterator<Item = &TerminalTab> {
self.groups.iter().flat_map(|g| g.tabs.iter())
}
pub fn any_alive(&self) -> bool {
self.tabs().any(TerminalTab::is_alive)
}
pub fn find(&self, id: TabId) -> Option<TabAddr> {
self.groups.iter().enumerate().find_map(|(group, g)| {
g.tabs
.iter()
.position(|t| t.id() == id)
.map(|tab| TabAddr { group, tab })
})
}
pub fn tab_mut(&mut self, addr: TabAddr) -> Option<&mut TerminalTab> {
self.groups.get_mut(addr.group)?.tabs.get_mut(addr.tab)
}
pub fn find_in_worktree(&self, worktree: &Path, kind: TabKind) -> Option<TabAddr> {
self.groups.iter().enumerate().find_map(|(group, g)| {
g.tabs
.iter()
.position(|t| t.opened_in == worktree && t.kind == kind && t.is_alive())
.map(|tab| TabAddr { group, tab })
})
}
pub fn focus(&mut self, addr: TabAddr) {
if let Some(group) = self.groups.get_mut(addr.group) {
if addr.tab < group.tabs.len() {
group.active = addr.tab;
self.focused = addr.group;
}
}
}
pub fn open_tab(
&mut self,
spawn: impl FnOnce(TabId) -> Result<TerminalTab>,
) -> Result<TabAddr> {
let id = self.take_id();
let tab = spawn(id)?;
if self.groups.is_empty() {
self.groups.push(PaneGroup::new(tab));
self.weights = layout::even_weights(1);
self.focused = 0;
return Ok(TabAddr { group: 0, tab: 0 });
}
let group = self.focused.min(self.groups.len() - 1);
self.groups[group].tabs.push(tab);
let tab = self.groups[group].tabs.len() - 1;
self.groups[group].active = tab;
self.focused = group;
Ok(TabAddr { group, tab })
}
pub fn split(&mut self, spawn: impl FnOnce(TabId) -> Result<TerminalTab>) -> Result<TabAddr> {
if self.groups.is_empty() {
return self.open_tab(spawn);
}
let id = self.take_id();
let tab = spawn(id)?;
let at = (self.focused + 1).min(self.groups.len());
self.groups.insert(at, PaneGroup::new(tab));
self.weights.insert(at, average_weight(&self.weights));
self.focused = at;
Ok(TabAddr { group: at, tab: 0 })
}
pub fn close_tab(&mut self, addr: TabAddr) -> Option<PathBuf> {
let group = self.groups.get_mut(addr.group)?;
if addr.tab >= group.tabs.len() {
return None;
}
let mut tab = group.tabs.remove(addr.tab);
tab.shutdown();
group.clamp_active();
if group.tabs.is_empty() {
self.groups.remove(addr.group);
if addr.group < self.weights.len() {
self.weights.remove(addr.group);
}
if self.focused >= self.groups.len() {
self.focused = self.groups.len().saturating_sub(1);
}
} else {
self.focused = addr.group;
}
Some(tab.opened_in)
}
pub fn close_other_tabs(&mut self, addr: TabAddr) -> Vec<PathBuf> {
let Some(group) = self.groups.get_mut(addr.group) else {
return Vec::new();
};
if addr.tab >= group.tabs.len() {
return Vec::new();
}
let mut closed = Vec::with_capacity(group.tabs.len() - 1);
for index in (0..group.tabs.len()).rev() {
if index == addr.tab {
continue;
}
let mut tab = group.tabs.remove(index);
tab.shutdown();
closed.push(tab.opened_in);
}
closed.reverse(); group.active = 0;
group.clamp_active();
self.focused = addr.group;
closed
}
pub fn close_tabs_to_right(&mut self, addr: TabAddr) -> Vec<PathBuf> {
let Some(group) = self.groups.get_mut(addr.group) else {
return Vec::new();
};
if addr.tab >= group.tabs.len() {
return Vec::new();
}
let mut closed = Vec::with_capacity(group.tabs.len() - addr.tab - 1);
for index in (addr.tab + 1..group.tabs.len()).rev() {
let mut tab = group.tabs.remove(index);
tab.shutdown();
closed.push(tab.opened_in);
}
closed.reverse(); group.clamp_active();
self.focused = addr.group;
closed
}
pub fn close_group(&mut self, index: usize) -> Vec<PathBuf> {
let Some(group) = self.groups.get_mut(index) else {
return Vec::new();
};
let mut closed = Vec::with_capacity(group.tabs.len());
for tab_index in (0..group.tabs.len()).rev() {
let mut tab = group.tabs.remove(tab_index);
tab.shutdown();
closed.push(tab.opened_in);
}
closed.reverse(); self.groups.remove(index);
if index < self.weights.len() {
self.weights.remove(index);
}
if self.focused >= self.groups.len() {
self.focused = self.groups.len().saturating_sub(1);
}
closed
}
pub fn close_active(&mut self) -> Option<PathBuf> {
let addr = TabAddr {
group: self.focused,
tab: self.focused_group()?.active,
};
self.close_tab(addr)
}
pub fn open_worktrees(&self) -> HashSet<PathBuf> {
self.tabs().map(|t| t.opened_in.clone()).collect()
}
pub fn cycle_tab(&mut self, delta: isize) {
let Some(group) = self.groups.get_mut(self.focused) else {
return;
};
let len = group.tabs.len();
if len == 0 {
return;
}
let len_i = isize::try_from(len).unwrap_or(isize::MAX);
let current = isize::try_from(group.active).unwrap_or(0);
group.active = usize::try_from((current + delta).rem_euclid(len_i)).unwrap_or(0);
}
pub fn select_tab(&mut self, index: usize) -> bool {
match self.groups.get_mut(self.focused) {
Some(group) if index < group.tabs.len() => {
group.active = index;
true
}
_ => false,
}
}
pub fn cycle_group(&mut self, delta: isize) {
if self.groups.is_empty() {
return;
}
let max = isize::try_from(self.groups.len() - 1).unwrap_or(0);
let current = isize::try_from(self.focused).unwrap_or(0);
self.focused = usize::try_from((current + delta).clamp(0, max)).unwrap_or(0);
}
pub fn move_tab_to_group(&mut self, delta: isize) -> bool {
if self.groups.len() < 2 {
return false;
}
let from = self.focused;
let Ok(target) = usize::try_from(isize::try_from(from).unwrap_or(0) + delta) else {
return false;
};
if target >= self.groups.len() {
return false;
}
let Some(group) = self.groups.get_mut(from) else {
return false;
};
if group.tabs.is_empty() {
return false;
}
let tab = group.tabs.remove(group.active);
group.clamp_active();
let emptied = group.tabs.is_empty();
let target = if emptied && target > from {
target - 1
} else {
target
};
if emptied {
self.groups.remove(from);
if from < self.weights.len() {
self.weights.remove(from);
}
}
let Some(dest) = self.groups.get_mut(target) else {
return false; };
dest.tabs.push(tab);
dest.active = dest.tabs.len() - 1;
self.focused = target;
true
}
pub fn reset_weights(&mut self) {
self.weights = layout::even_weights(self.groups.len());
}
pub fn to_saved(&self) -> session_layout::SavedLayout {
session_layout::SavedLayout {
version: 1,
groups: self
.groups
.iter()
.enumerate()
.map(|(index, group)| session_layout::SavedGroup {
tabs: group
.tabs
.iter()
.map(|tab| session_layout::SavedTab {
path: tab.opened_in.clone(),
kind: tab.kind.into(),
})
.collect(),
active: group.active,
weight: self.weights.get(index).copied().unwrap_or(1),
})
.collect(),
focused: self.focused,
}
}
pub fn restore(
&mut self,
saved: &session_layout::SavedLayout,
mut spawn: impl FnMut(TabId, TabKind, &Path) -> Result<TerminalTab>,
) -> usize {
let mut failed = 0;
for group in &saved.groups {
let mut tabs = Vec::with_capacity(group.tabs.len());
for saved_tab in &group.tabs {
let id = self.take_id();
match spawn(id, saved_tab.kind.into(), &saved_tab.path) {
Ok(tab) => tabs.push(tab),
Err(_) => failed += 1,
}
}
if tabs.is_empty() {
continue;
}
let active = group.active.min(tabs.len() - 1);
self.groups.push(PaneGroup { tabs, active });
self.weights.push(group.weight.max(1));
}
self.focused = saved.focused.min(self.groups.len().saturating_sub(1));
failed
}
pub fn handle_event(&mut self, id: TabId, event: TermEvent) -> Option<TabEffect> {
let addr = self.find(id)?;
Some(self.tab_mut(addr)?.handle_event(event))
}
pub fn arrange(&mut self, area: Rect) -> Vec<GroupRects> {
let rects = layout::split_groups(area, &self.weights);
let mut out = Vec::with_capacity(rects.len());
for (index, rect) in rects.into_iter().enumerate() {
let strip = Rect::new(rect.x, rect.y, rect.width, 1);
let body = Rect::new(
rect.x,
rect.y + 1,
rect.width,
rect.height.saturating_sub(1),
);
let grid = Block::default().borders(Borders::ALL).inner(body);
if let Some(tab) = self
.groups
.get_mut(index)
.and_then(PaneGroup::active_tab_mut)
{
tab.resize(GridSize {
cols: grid.width,
lines: grid.height,
});
}
let tab_spans = self
.groups
.get(index)
.map(|g| strip_cells(g, strip).iter().map(|c| c.span).collect())
.unwrap_or_default();
out.push(GroupRects {
strip,
body,
grid,
index,
tab_spans,
});
}
out
}
pub fn draw(&self, frame: &mut Frame<'_>, rects: &[GroupRects], focused_pane: bool) {
for group_rects in rects {
let Some(group) = self.groups.get(group_rects.index) else {
continue;
};
let is_focused = focused_pane && group_rects.index == self.focused;
draw_tab_strip(frame, group_rects.strip, group, is_focused);
if let Some(tab) = group.active_tab() {
tab.draw(frame, group_rects.body, is_focused);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupRects {
pub strip: Rect,
pub body: Rect,
pub grid: Rect,
pub index: usize,
pub tab_spans: Vec<(u16, u16)>,
}
struct StripCell {
text: String,
span: (u16, u16),
}
fn strip_cells(group: &PaneGroup, area: Rect) -> Vec<StripCell> {
let mut cells = Vec::with_capacity(group.tabs.len());
let mut x = area.x;
let end = area.x.saturating_add(area.width);
for (index, tab) in group.tabs.iter().enumerate() {
let text = format!(" {} {} ", index + 1, tab.strip_label());
let width = u16::try_from(text.width()).unwrap_or(u16::MAX);
if x >= end {
break; }
let stop = x.saturating_add(width).min(end);
cells.push(StripCell {
text,
span: (x, stop),
});
x = stop.saturating_add(1);
}
cells
}
fn average_weight(weights: &[u16]) -> u16 {
if weights.is_empty() {
return 1;
}
let total: u32 = weights.iter().map(|w| u32::from(*w)).sum();
u16::try_from(total / u32::try_from(weights.len()).unwrap_or(1))
.unwrap_or(1)
.max(1)
}
fn draw_tab_strip(frame: &mut Frame<'_>, area: Rect, group: &PaneGroup, focused: bool) {
if area.height == 0 {
return;
}
let mut spans = Vec::with_capacity(group.tabs.len() * 2);
for (index, cell) in strip_cells(group, area).into_iter().enumerate() {
let mut style = Style::default();
if index == group.active {
style = style.add_modifier(Modifier::REVERSED);
if focused {
style = style.fg(Color::Cyan);
}
} else {
style = style.fg(Color::DarkGray);
}
spans.push(Span::styled(cell.text, style));
spans.push(Span::raw(" "));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
pub fn min_height_for(count: usize) -> u16 {
u16::try_from(count)
.unwrap_or(u16::MAX)
.saturating_mul(MIN_GROUP_HEIGHT)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[cfg_attr(not(unix), allow(unused_imports))]
use tokio::sync::mpsc;
#[cfg(unix)]
fn spawner(
script: &'static str,
cwd: PathBuf,
tx: mpsc::UnboundedSender<(TabId, TermEvent)>,
) -> impl FnOnce(TabId) -> Result<TerminalTab> {
move |id| {
let request = super::super::terminal::pty::SpawnRequest {
tab: id,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), script.to_string()],
)),
cwd,
size: GridSize { cols: 40, lines: 6 },
extra_env: Vec::new(),
};
TerminalTab::from_request(TabKind::Shell, request, tx)
}
}
#[cfg(unix)]
fn layout_with(count: usize) -> (PaneLayout, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let (tx, _rx) = mpsc::unbounded_channel();
let mut panes = PaneLayout::default();
for _ in 0..count {
panes
.split(spawner("sleep 5", dir.path().to_path_buf(), tx.clone()))
.unwrap();
}
(panes, dir)
}
#[test]
fn an_empty_layout_has_no_groups_and_no_active_tab() {
let panes = PaneLayout::default();
assert!(panes.is_empty());
assert_eq!(panes.group_count(), 0);
assert!(panes.active_tab().is_none());
assert!(!panes.any_alive());
assert!(panes.find(1).is_none());
assert!(panes.group_tab(0).is_none());
assert!(panes.open_worktrees().is_empty());
}
#[test]
fn average_weight_of_nothing_is_one() {
assert_eq!(average_weight(&[]), 1);
assert_eq!(average_weight(&[2, 4]), 3);
assert_eq!(average_weight(&[0, 0]), 1, "never zero");
}
#[test]
fn min_height_scales_with_the_group_count() {
assert_eq!(min_height_for(1), MIN_GROUP_HEIGHT);
assert_eq!(min_height_for(3), MIN_GROUP_HEIGHT * 3);
}
#[cfg(unix)]
#[tokio::test]
async fn opening_adds_to_the_focused_group_and_splitting_makes_a_new_one() {
let (mut panes, dir) = layout_with(0);
let (tx, _rx) = mpsc::unbounded_channel();
let spawn = || spawner("sleep 5", dir.path().to_path_buf(), tx.clone());
let first = panes.open_tab(spawn()).unwrap();
assert_eq!(first, TabAddr { group: 0, tab: 0 });
assert_eq!(panes.group_count(), 1);
assert_eq!(panes.weights, vec![1]);
assert!(panes.any_alive());
let second = panes.open_tab(spawn()).unwrap();
assert_eq!(second, TabAddr { group: 0, tab: 1 });
assert_eq!(panes.group_count(), 1);
assert_eq!(panes.groups[0].tabs.len(), 2);
assert_eq!(panes.groups[0].active, 1, "a new tab takes focus");
let split = panes.split(spawn()).unwrap();
assert_eq!(split, TabAddr { group: 1, tab: 0 });
assert_eq!(panes.group_count(), 2);
assert_eq!(panes.weights.len(), 2);
assert_eq!(panes.focused, 1);
let ids: Vec<TabId> = panes.tabs().map(TerminalTab::id).collect();
let unique: HashSet<TabId> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len());
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn closing_removes_the_tab_then_its_group_and_reports_the_worktree() {
let (mut panes, dir) = layout_with(2);
let here = dir.path().to_path_buf();
assert_eq!(panes.group_count(), 2);
let closed = panes.close_active().unwrap();
assert_eq!(closed, here);
assert_eq!(panes.group_count(), 1, "the emptied group went with it");
assert_eq!(panes.weights.len(), 1);
assert_eq!(panes.focused, 0);
assert!(panes.open_worktrees().contains(&here), "one tab remains");
assert_eq!(panes.close_active(), Some(here));
assert!(panes.is_empty());
assert!(panes.open_worktrees().is_empty());
assert_eq!(panes.focused, 0);
assert!(panes.close_active().is_none(), "nothing left to close");
assert!(panes.close_tab(TabAddr { group: 9, tab: 9 }).is_none());
}
#[cfg(unix)]
#[tokio::test]
async fn close_other_tabs_leaves_the_anchor_and_reports_every_closed_worktree() {
let (mut panes, _dir) = layout_with(0);
let (tx, _rx) = mpsc::unbounded_channel();
let dirs: Vec<tempfile::TempDir> = (0..4).map(|_| tempfile::tempdir().unwrap()).collect();
for d in &dirs {
panes
.open_tab(spawner("sleep 5", d.path().to_path_buf(), tx.clone()))
.unwrap();
}
assert_eq!(panes.groups[0].tabs.len(), 4);
let anchor = dirs[1].path().to_path_buf();
let closed = panes.close_other_tabs(TabAddr { group: 0, tab: 1 });
assert_eq!(panes.group_count(), 1, "never empties the group");
assert_eq!(panes.groups[0].tabs.len(), 1, "only the anchor remains");
assert_eq!(panes.groups[0].tabs[0].opened_in, anchor);
assert_eq!(
panes.groups[0].active, 0,
"the survivor is now the active tab"
);
assert_eq!(panes.focused, 0);
let expected: HashSet<PathBuf> = [
dirs[0].path().to_path_buf(),
dirs[2].path().to_path_buf(),
dirs[3].path().to_path_buf(),
]
.into_iter()
.collect();
let got: HashSet<PathBuf> = closed.iter().cloned().collect();
assert_eq!(
got, expected,
"every non-anchor tab is reported, not just one"
);
assert_eq!(closed.len(), 3, "no duplicates, no anchor");
assert!(panes
.close_other_tabs(TabAddr { group: 9, tab: 0 })
.is_empty());
assert!(panes
.close_other_tabs(TabAddr { group: 0, tab: 9 })
.is_empty());
assert_eq!(
panes.groups[0].tabs.len(),
1,
"the bad addresses touched nothing"
);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn close_tabs_to_right_leaves_the_anchor_and_everything_left_of_it() {
let (mut panes, _dir) = layout_with(0);
let (tx, _rx) = mpsc::unbounded_channel();
let dirs: Vec<tempfile::TempDir> = (0..4).map(|_| tempfile::tempdir().unwrap()).collect();
for d in &dirs {
panes
.open_tab(spawner("sleep 5", d.path().to_path_buf(), tx.clone()))
.unwrap();
}
assert_eq!(panes.groups[0].active, 3);
let closed = panes.close_tabs_to_right(TabAddr { group: 0, tab: 1 });
assert_eq!(panes.group_count(), 1, "never empties the group");
assert_eq!(
panes.groups[0].tabs.len(),
2,
"the anchor and everything left of it survive"
);
assert_eq!(panes.groups[0].tabs[0].opened_in, dirs[0].path());
assert_eq!(panes.groups[0].tabs[1].opened_in, dirs[1].path());
assert_eq!(
panes.groups[0].active, 1,
"active is clamped back into range"
);
assert_eq!(panes.focused, 0);
assert_eq!(
closed,
vec![dirs[2].path().to_path_buf(), dirs[3].path().to_path_buf()],
"every tab to the right is reported, in close order, not just one"
);
assert!(panes
.close_tabs_to_right(TabAddr { group: 9, tab: 0 })
.is_empty());
assert!(panes
.close_tabs_to_right(TabAddr { group: 0, tab: 9 })
.is_empty());
assert_eq!(
panes.groups[0].tabs.len(),
2,
"the bad addresses touched nothing"
);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn close_group_removes_the_group_and_reports_every_closed_worktree() {
let (mut panes, _dir) = layout_with(0);
let (tx, _rx) = mpsc::unbounded_channel();
let dirs: Vec<tempfile::TempDir> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect();
for d in &dirs {
panes
.split(spawner("sleep 5", d.path().to_path_buf(), tx.clone()))
.unwrap();
}
assert_eq!(panes.group_count(), 3);
panes.focused = 1;
let extra_dir = tempfile::tempdir().unwrap();
panes
.open_tab(spawner("sleep 5", extra_dir.path().to_path_buf(), tx))
.unwrap();
assert_eq!(panes.groups[1].tabs.len(), 2);
panes.weights = vec![1, 2, 3];
panes.focused = 2;
let closed = panes.close_group(1);
assert_eq!(panes.group_count(), 2, "the closed group is removed");
assert_eq!(panes.weights, vec![1, 3], "its weight goes with it");
let expected: HashSet<PathBuf> =
[dirs[1].path().to_path_buf(), extra_dir.path().to_path_buf()]
.into_iter()
.collect();
let got: HashSet<PathBuf> = closed.iter().cloned().collect();
assert_eq!(
got, expected,
"every tab in the group is reported, not just one"
);
assert_eq!(closed.len(), 2, "no duplicates");
assert_eq!(
panes.focused, 1,
"a later focus shifts down with the removed group"
);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn close_group_clamps_focus_when_it_was_the_last_group() {
let (mut panes, dir) = layout_with(1);
let here = dir.path().to_path_buf();
assert_eq!(panes.group_count(), 1);
let closed = panes.close_group(0);
assert_eq!(closed, vec![here], "the only tab is reported");
assert!(panes.is_empty(), "the layout is now empty");
assert!(panes.weights.is_empty());
assert_eq!(
panes.focused, 0,
"focus is clamped back into range, not left dangling"
);
assert!(panes.close_group(0).is_empty());
assert!(panes.close_group(9).is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn close_group_with_an_out_of_range_index_is_a_no_op() {
let (mut panes, _dir) = layout_with(2);
assert!(panes.close_group(9).is_empty());
assert_eq!(panes.group_count(), 2, "nothing was touched");
assert_eq!(panes.weights.len(), 2);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn tabs_and_groups_cycle_select_and_move() {
let (mut panes, dir) = layout_with(1);
let (tx, _rx) = mpsc::unbounded_channel();
let spawn = || spawner("sleep 5", dir.path().to_path_buf(), tx.clone());
panes.open_tab(spawn()).unwrap();
panes.open_tab(spawn()).unwrap(); assert_eq!(panes.groups[0].tabs.len(), 3);
assert_eq!(panes.groups[0].active, 2);
panes.cycle_tab(1);
assert_eq!(panes.groups[0].active, 0, "wraps forward");
panes.cycle_tab(-1);
assert_eq!(panes.groups[0].active, 2, "wraps back");
assert!(panes.select_tab(1));
assert_eq!(panes.groups[0].active, 1);
assert!(!panes.select_tab(9), "out of range selects nothing");
assert_eq!(panes.groups[0].active, 1);
panes.split(spawn()).unwrap();
assert_eq!(panes.focused, 1);
panes.cycle_group(-1);
assert_eq!(panes.focused, 0);
panes.cycle_group(-1);
assert_eq!(panes.focused, 0, "clamped, not wrapped");
panes.cycle_group(5);
assert_eq!(panes.focused, 1);
assert!(panes.move_tab_to_group(-1));
assert_eq!(panes.group_count(), 1);
assert_eq!(panes.focused, 0);
assert_eq!(panes.groups[0].tabs.len(), 4);
assert!(
!panes.move_tab_to_group(-1),
"one group: nothing to move to"
);
panes.reset_weights();
assert_eq!(panes.weights, vec![1]);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn events_route_by_tab_id_and_unknown_ids_are_ignored() {
let (mut panes, _dir) = layout_with(2);
let ids: Vec<TabId> = panes.tabs().map(TerminalTab::id).collect();
assert_eq!(
panes.handle_event(ids[0], TermEvent::Title("t".to_string())),
Some(TabEffect::Redraw)
);
assert_eq!(panes.groups[0].tabs[0].title.as_deref(), Some("t"));
assert!(
panes.groups[1].tabs[0].title.is_none(),
"the other tab is untouched"
);
assert_eq!(panes.handle_event(9999, TermEvent::Wakeup), None);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn arrange_sizes_every_group_and_draw_renders_the_strip() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let (mut panes, _dir) = layout_with(2);
let area = Rect::new(0, 0, 50, 24);
let rects = panes.arrange(area);
assert_eq!(rects.len(), 2);
assert_eq!(rects[0].strip.height, 1);
assert_eq!(rects[1].body.y, rects[0].body.y + rects[0].body.height + 1);
for r in &rects {
assert!(r.grid.width < r.body.width);
assert_eq!(r.grid.y, r.body.y + 1);
}
let mut terminal = Terminal::new(TestBackend::new(50, 24)).unwrap();
terminal
.draw(|frame| {
let rects = panes.arrange(frame.area());
panes.draw(frame, &rects, true);
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(text.contains("1 shell"), "the tab strip names the tab");
let short = panes.arrange(Rect::new(0, 0, 50, 5));
assert_eq!(short.len(), 1);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn a_layout_survives_a_save_and_restore_round_trip() {
let (mut panes, dir) = layout_with(2);
let here = dir.path().to_path_buf();
let (tx, _rx) = mpsc::unbounded_channel();
panes.focused = 0;
panes
.open_tab(spawner("sleep 5", here.clone(), tx.clone()))
.unwrap();
panes.weights = vec![3, 1];
panes.focused = 1;
let saved = panes.to_saved();
assert_eq!(saved.groups.len(), 2);
assert_eq!(saved.groups[0].tabs.len(), 2);
assert_eq!(saved.groups[0].weight, 3);
assert_eq!(saved.focused, 1);
assert_eq!(saved.groups[0].tabs[0].path, here);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
let mut restored = PaneLayout::default();
let failed = restored.restore(&saved, |id, kind, path| {
let request = super::super::terminal::pty::SpawnRequest {
tab: id,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), "sleep 5".to_string()],
)),
cwd: path.to_path_buf(),
size: GridSize { cols: 40, lines: 6 },
extra_env: Vec::new(),
};
TerminalTab::from_request(kind, request, tx.clone())
});
assert_eq!(failed, 0);
assert_eq!(restored.group_count(), 2);
assert_eq!(restored.groups[0].tabs.len(), 2);
assert_eq!(restored.weights, vec![3, 1]);
assert_eq!(restored.focused, 1);
assert!(restored.any_alive(), "restored tabs are live children");
assert!(restored.tabs().all(|t| t.id() > 0));
for tab in restored.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn restore_skips_tabs_that_will_not_spawn_and_drops_emptied_groups() {
let dir = tempfile::tempdir().unwrap();
let saved = session_layout::SavedLayout {
version: 1,
groups: vec![
session_layout::SavedGroup {
tabs: vec![session_layout::SavedTab {
path: dir.path().to_path_buf(),
kind: session_layout::SavedTabKind::Shell,
}],
active: 0,
weight: 1,
},
session_layout::SavedGroup {
tabs: vec![session_layout::SavedTab {
path: dir.path().to_path_buf(),
kind: session_layout::SavedTabKind::Claude,
}],
active: 0,
weight: 1,
},
],
focused: 1,
};
let (tx, _rx) = mpsc::unbounded_channel();
let mut panes = PaneLayout::default();
let failed = panes.restore(&saved, |id, kind, path| {
if kind == TabKind::Claude {
anyhow::bail!("no such program");
}
let request = super::super::terminal::pty::SpawnRequest {
tab: id,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), "sleep 5".to_string()],
)),
cwd: path.to_path_buf(),
size: GridSize { cols: 40, lines: 6 },
extra_env: Vec::new(),
};
TerminalTab::from_request(kind, request, tx.clone())
});
assert_eq!(failed, 1, "the failure is counted, not swallowed");
assert_eq!(panes.group_count(), 1, "its emptied group is dropped");
assert_eq!(panes.focused, 0, "focus is clamped back into range");
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[test]
fn an_empty_layout_saves_as_empty() {
let panes = PaneLayout::default();
assert!(panes.to_saved().is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn find_in_worktree_matches_kind_and_liveness() {
let (mut panes, dir) = layout_with(1);
let here = dir.path().to_path_buf();
assert_eq!(
panes.find_in_worktree(&here, TabKind::Shell),
Some(TabAddr { group: 0, tab: 0 })
);
assert!(panes.find_in_worktree(&here, TabKind::Claude).is_none());
assert!(panes
.find_in_worktree(Path::new("/nowhere"), TabKind::Shell)
.is_none());
panes.groups[0].tabs[0].exit_status = Some(std::process::ExitStatus::default());
assert!(panes.find_in_worktree(&here, TabKind::Shell).is_none());
assert!(!panes.any_alive());
panes.focus(TabAddr { group: 9, tab: 9 });
assert_eq!(panes.focused, 0);
panes.focus(TabAddr { group: 0, tab: 0 });
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
}