use std::collections::HashMap;
use std::process::Command;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
pub fn triptych_detail_area(total: Rect, has_pending_approvals: bool) -> Option<Rect> {
if total.width == 0 || total.height == 0 {
return None;
}
let body = if has_pending_approvals {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(total);
v[1]
} else {
total
};
let outer = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(28), Constraint::Min(0), ])
.split(body);
let right_stack = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)])
.split(outer[1]);
let detail = right_stack[0];
if detail.width == 0 || detail.height == 0 {
return None;
}
Some(detail)
}
pub fn should_sync(
cache: &HashMap<String, (u16, u16)>,
session: &str,
current: (u16, u16),
) -> bool {
cache.get(session) != Some(¤t)
}
pub trait PaneResizer: Send + Sync {
fn resize(&self, session: &str, cols: u16, rows: u16);
}
fn resize_window_argv(session: &str, cols: u16, rows: u16) -> [String; 7] {
[
"resize-window".to_string(),
"-t".to_string(),
session.to_string(),
"-x".to_string(),
cols.to_string(),
"-y".to_string(),
rows.to_string(),
]
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TmuxPaneResizer;
impl PaneResizer for TmuxPaneResizer {
fn resize(&self, session: &str, cols: u16, rows: u16) {
let _ = Command::new("tmux")
.args(resize_window_argv(session, cols, rows))
.status();
}
}
pub mod test_support {
use std::sync::Mutex;
use super::PaneResizer;
#[derive(Debug, Default)]
pub struct MockPaneResizer {
pub calls: Mutex<Vec<(String, u16, u16)>>,
}
impl PaneResizer for MockPaneResizer {
fn resize(&self, session: &str, cols: u16, rows: u16) {
self.calls
.lock()
.unwrap()
.push((session.to_string(), cols, rows));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detail_area_for_typical_terminal_without_approvals() {
let total = Rect::new(0, 0, 120, 40);
let detail = triptych_detail_area(total, false).unwrap();
assert_eq!(detail.x, 28);
assert_eq!(detail.y, 0);
assert_eq!(detail.width, 92);
assert_eq!(detail.height, 24);
}
#[test]
fn detail_area_with_approvals_stripe_loses_one_row() {
let total = Rect::new(0, 0, 120, 40);
let detail = triptych_detail_area(total, true).unwrap();
assert_eq!(detail.x, 28);
assert_eq!(detail.y, 1);
assert_eq!(detail.width, 92);
assert_eq!(detail.height, 23);
}
#[test]
fn detail_area_returns_none_on_zero_dimension() {
assert!(triptych_detail_area(Rect::new(0, 0, 0, 40), false).is_none());
assert!(triptych_detail_area(Rect::new(0, 0, 120, 0), false).is_none());
}
#[test]
fn detail_area_returns_none_when_sidebar_consumes_everything() {
let total = Rect::new(0, 0, 28, 40);
assert!(triptych_detail_area(total, false).is_none());
}
#[test]
fn should_sync_returns_true_on_first_call() {
let cache = HashMap::new();
assert!(should_sync(&cache, "t-hello-mgr", (92, 24)));
}
#[test]
fn should_sync_returns_false_when_size_unchanged() {
let mut cache = HashMap::new();
cache.insert("t-hello-mgr".to_string(), (92, 24));
assert!(!should_sync(&cache, "t-hello-mgr", (92, 24)));
}
#[test]
fn should_sync_returns_true_when_size_differs() {
let mut cache = HashMap::new();
cache.insert("t-hello-mgr".to_string(), (92, 24));
assert!(should_sync(&cache, "t-hello-mgr", (100, 24)));
assert!(should_sync(&cache, "t-hello-mgr", (92, 25)));
}
#[test]
fn should_sync_treats_different_sessions_independently() {
let mut cache = HashMap::new();
cache.insert("t-hello-mgr".to_string(), (92, 24));
assert!(should_sync(&cache, "t-hello-dev", (92, 24)));
}
use super::test_support::MockPaneResizer;
#[test]
fn mock_resizer_records_calls() {
let m = MockPaneResizer::default();
m.resize("t-a", 100, 30);
m.resize("t-b", 80, 20);
let calls = m.calls.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0], ("t-a".to_string(), 100, 30));
assert_eq!(calls[1], ("t-b".to_string(), 80, 20));
}
#[test]
fn resize_argv_is_resize_window_never_resize_pane() {
let argv = super::resize_window_argv("t-hello-mgr", 92, 24);
assert_eq!(
argv[0], "resize-window",
"MUST be `resize-window`: `resize-pane` silently no-ops on \
the sole pane of a clientless detached session and reopens \
#312"
);
assert_ne!(argv[0], "resize-pane", "the #312 regression verb");
assert_eq!(
argv,
["resize-window", "-t", "t-hello-mgr", "-x", "92", "-y", "24"].map(str::to_string)
);
}
#[test]
#[ignore = "spawns a real tmux server; run with --ignored on a tmux host"]
fn resize_window_actually_shrinks_a_clientless_session() {
let session = "t312-regression-probe";
let kill = || {
let _ = Command::new("tmux")
.args(["kill-session", "-t", session])
.status();
};
kill();
let created = Command::new("tmux")
.args([
"new-session",
"-d",
"-x",
"200",
"-y",
"50",
"-s",
session,
"sh",
"-c",
"while :; do sleep 5; done",
])
.status();
if !matches!(created, Ok(s) if s.success()) {
return;
}
TmuxPaneResizer.resize(session, 80, 24);
let out = Command::new("tmux")
.args([
"display-message",
"-p",
"-t",
session,
"#{window_width}x#{window_height}",
])
.output()
.expect("tmux display-message");
let geom = String::from_utf8_lossy(&out.stdout).trim().to_string();
kill();
assert_eq!(
geom, "80x24",
"resizer did not shrink the clientless window (got `{geom}`) \
— the resize-pane regression (#312)"
);
}
}