use std::collections::VecDeque;
use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Sparkline, Table, Wrap,
};
use super::tmux_query::{Client, Pane, Snapshot, poll, ztmux_cmd};
enum TreeRow {
Session(usize), Window(usize), }
struct App {
snap: Snapshot,
socket: String,
rows: Vec<TreeRow>,
list: ListState,
history: VecDeque<u64>, last_refresh: Instant,
refresh_every: Duration,
confirm_kill: bool,
status: String,
quit: bool,
}
impl App {
fn new(socket: String) -> Self {
let mut app = App {
snap: Snapshot::default(),
socket,
rows: Vec::new(),
list: ListState::default(),
history: VecDeque::new(),
last_refresh: Instant::now(),
refresh_every: Duration::from_millis(1000),
confirm_kill: false,
status: String::from("connected"),
quit: false,
};
app.refresh();
app.list.select(Some(0));
app
}
fn refresh(&mut self) {
self.snap = poll(&self.socket);
self.rebuild_rows();
let total = self.snap.panes.len() as u64;
self.history.push_back(total);
while self.history.len() > 120 {
self.history.pop_front();
}
self.last_refresh = Instant::now();
if self.rows.is_empty() {
self.list.select(None);
} else {
let sel = self.list.selected().unwrap_or(0).min(self.rows.len() - 1);
self.list.select(Some(sel));
}
}
fn rebuild_rows(&mut self) {
self.rows.clear();
for (si, s) in self.snap.sessions.iter().enumerate() {
self.rows.push(TreeRow::Session(si));
let mut wins: Vec<usize> = self
.snap
.windows
.iter()
.enumerate()
.filter(|(_, w)| w.session == s.name)
.map(|(i, _)| i)
.collect();
wins.sort_by_key(|&i| self.snap.windows[i].index);
for wi in wins {
self.rows.push(TreeRow::Window(wi));
}
}
}
fn move_sel(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let n = self.rows.len() as isize;
let cur = self.list.selected().unwrap_or(0) as isize;
let next = (cur + delta).rem_euclid(n);
self.list.select(Some(next as usize));
}
fn select_edge(&mut self, top: bool) {
if self.rows.is_empty() {
return;
}
self.list
.select(Some(if top { 0 } else { self.rows.len() - 1 }));
}
fn kill_selected(&mut self) {
let Some(sel) = self.list.selected() else {
return;
};
let (cmd, target, label) = match self.rows.get(sel) {
Some(TreeRow::Session(si)) => {
let s = &self.snap.sessions[*si];
(
"kill-session",
s.name.clone(),
format!("session {}", s.name),
)
}
Some(TreeRow::Window(wi)) => {
let w = &self.snap.windows[*wi];
(
"kill-window",
format!("{}:{}", w.session, w.index),
format!("window {}:{}", w.session, w.index),
)
}
None => return,
};
let out = ztmux_cmd(&self.socket, &[cmd, "-t", &target]).output();
self.status = match out {
Ok(o) if o.status.success() => format!("killed {label}"),
Ok(o) => format!("kill failed: {}", String::from_utf8_lossy(&o.stderr).trim()),
Err(e) => format!("kill failed: {e}"),
};
self.refresh();
}
}
pub(crate) fn run(socket: &str) -> i32 {
let mut terminal = ratatui::init();
let mut app = App::new(socket.to_string());
let result = (|| -> std::io::Result<()> {
while !app.quit {
terminal.draw(|f| ui(f, &mut app))?;
let timeout = app.refresh_every.saturating_sub(app.last_refresh.elapsed());
if event::poll(timeout)? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press {
handle_key(k.code, &mut app);
}
}
}
if app.last_refresh.elapsed() >= app.refresh_every {
app.refresh();
}
}
Ok(())
})();
ratatui::restore();
match result {
Ok(()) => 0,
Err(e) => {
eprintln!("dashboard error: {e}");
1
}
}
}
fn handle_key(code: KeyCode, app: &mut App) {
if app.confirm_kill {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
app.confirm_kill = false;
app.kill_selected();
}
_ => {
app.confirm_kill = false;
app.status = "kill cancelled".into();
}
}
return;
}
match code {
KeyCode::Char('q') | KeyCode::Esc => app.quit = true,
KeyCode::Char('j') | KeyCode::Down => app.move_sel(1),
KeyCode::Char('k') | KeyCode::Up => app.move_sel(-1),
KeyCode::Char('g') | KeyCode::Home => app.select_edge(true),
KeyCode::Char('G') | KeyCode::End => app.select_edge(false),
KeyCode::Char('r') => {
app.refresh();
app.status = "refreshed".into();
}
KeyCode::Char('x') => {
if app.list.selected().is_some() && !app.rows.is_empty() {
app.confirm_kill = true;
}
}
_ => {}
}
}
fn ui(f: &mut Frame, app: &mut App) {
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), Constraint::Min(3), Constraint::Length(1), ])
.split(f.area());
render_header(f, app, root[0]);
if let Some(err) = app.snap.error.clone() {
let msg = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
" no ztmux server reachable",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(format!(" {err}")),
Line::from(""),
Line::from(" start one with `ztmux new-session`, then press r"),
])
.block(Block::default().borders(Borders::ALL).title(" ztmux "));
f.render_widget(msg, root[1]);
} else {
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.split(root[1]);
render_tree(f, app, body[0]);
render_detail(f, app, body[1]);
}
render_footer(f, app, root[2]);
if app.confirm_kill {
render_confirm(f, app);
}
}
fn render_header(f: &mut Frame, app: &App, area: Rect) {
let s = &app.snap;
let server = s.sessions.first();
let pid = "?"; let _ = pid;
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let attached = s.sessions.iter().filter(|x| x.attached).count();
let stats = Paragraph::new(vec![
Line::from(vec![
Span::styled(
" ztmux dashboard ",
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("{} sessions", s.sessions.len()),
Style::default().fg(Color::Cyan),
),
Span::raw(" · "),
Span::styled(
format!("{} windows", s.windows.len()),
Style::default().fg(Color::Green),
),
Span::raw(" · "),
Span::styled(
format!("{} panes", s.panes.len()),
Style::default().fg(Color::Yellow),
),
Span::raw(" · "),
Span::styled(
format!("{} clients", s.clients.len()),
Style::default().fg(Color::Magenta),
),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{attached} attached"),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(
match server {
Some(_) => "● live".to_string(),
None => "○ idle".to_string(),
},
Style::default().fg(Color::Green),
),
]),
])
.block(Block::default().borders(Borders::ALL));
f.render_widget(stats, cols[0]);
let spark_data: Vec<u64> = app.history.iter().copied().collect();
let max = spark_data.iter().copied().max().unwrap_or(1).max(1);
let spark = Sparkline::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" panes (max {max}) ")),
)
.data(&spark_data)
.style(Style::default().fg(Color::Yellow));
f.render_widget(spark, cols[1]);
}
fn render_tree(f: &mut Frame, app: &mut App, area: Rect) {
let items: Vec<ListItem> = app
.rows
.iter()
.map(|r| match r {
TreeRow::Session(si) => {
let s = &app.snap.sessions[*si];
let marker = if s.attached { "*" } else { " " };
ListItem::new(Line::from(vec![
Span::styled(
format!("▸ {} ", s.name),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({} win){}", s.windows, marker),
Style::default().fg(Color::DarkGray),
),
]))
}
TreeRow::Window(wi) => {
let w = &app.snap.windows[*wi];
let active = if w.active {
Span::styled(" ●", Style::default().fg(Color::Green))
} else {
Span::raw("")
};
ListItem::new(Line::from(vec![
Span::raw(format!(" {}: ", w.index)),
Span::styled(w.name.clone(), Style::default().fg(Color::White)),
Span::styled(
format!(" [{} panes]", w.panes),
Style::default().fg(Color::DarkGray),
),
active,
]))
}
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" sessions "))
.highlight_style(
Style::default()
.bg(Color::Indexed(237))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("❯ ");
f.render_stateful_widget(list, area, &mut app.list);
}
fn render_detail(f: &mut Frame, app: &App, area: Rect) {
let Some(sel) = app.list.selected() else {
let p = Paragraph::new("no selection")
.block(Block::default().borders(Borders::ALL).title(" detail "));
f.render_widget(p, area);
return;
};
match app.rows.get(sel) {
Some(TreeRow::Session(si)) => render_session_detail(f, app, *si, area),
Some(TreeRow::Window(wi)) => render_window_panes(f, app, *wi, area),
None => {}
}
}
fn render_session_detail(f: &mut Frame, app: &App, si: usize, area: Rect) {
let s = &app.snap.sessions[si];
let clients: Vec<&Client> = app
.snap
.clients
.iter()
.filter(|c| c.session == s.name)
.collect();
let mut lines = vec![
kv("session", &s.name),
kv("id", &s.id),
kv("windows", &s.windows.to_string()),
kv("attached", if s.attached { "yes" } else { "no" }),
kv("group", if s.group.is_empty() { "-" } else { &s.group }),
Line::from(""),
Line::from(Span::styled(
format!(" clients ({})", clients.len()),
Style::default().add_modifier(Modifier::BOLD),
)),
];
for c in clients {
lines.push(Line::from(format!(
" {} {} {}x{} {}",
c.name, c.tty, c.width, c.height, c.termname
)));
}
let p = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" session: {} ", s.name)),
)
.wrap(Wrap { trim: true });
f.render_widget(p, area);
}
fn render_window_panes(f: &mut Frame, app: &App, wi: usize, area: Rect) {
let w = &app.snap.windows[wi];
let panes: Vec<&Pane> = app
.snap
.panes
.iter()
.filter(|p| p.session == w.session && p.window == w.index)
.collect();
let header = Row::new(vec!["#", "id", "command", "pid", "size", "path"]).style(
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Cyan),
);
let rows: Vec<Row> = panes
.iter()
.map(|p| {
let flag = if p.active {
" ●"
} else if p.dead {
" ✝"
} else {
""
};
Row::new(vec![
Cell::from(format!("{}{}", p.index, flag)),
Cell::from(p.id.clone()),
Cell::from(p.command.clone()).style(Style::default().fg(Color::Green)),
Cell::from(p.pid.to_string()),
Cell::from(format!("{}x{}", p.width, p.height)),
Cell::from(shorten(&p.path, 30)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(12),
Constraint::Length(7),
Constraint::Length(8),
Constraint::Min(10),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(format!(
" {}:{} {} — {} panes ",
w.session, w.index, w.name, w.panes
)));
f.render_widget(table, area);
}
fn render_footer(f: &mut Frame, app: &App, area: Rect) {
let help = " j/k move · g/G top/bottom · r refresh · x kill · q quit";
let line = Line::from(vec![
Span::styled(
format!(" {} ", app.status),
Style::default().fg(Color::Black).bg(Color::DarkGray),
),
Span::styled(help, Style::default().fg(Color::DarkGray)),
]);
f.render_widget(Paragraph::new(line), area);
}
fn render_confirm(f: &mut Frame, app: &App) {
let area = centered_rect(50, 20, f.area());
let sel = app.list.selected().unwrap_or(0);
let what = match app.rows.get(sel) {
Some(TreeRow::Session(si)) => format!("session '{}'", app.snap.sessions[*si].name),
Some(TreeRow::Window(wi)) => {
let w = &app.snap.windows[*wi];
format!("window {}:{} '{}'", w.session, w.index, w.name)
}
None => "nothing".to_string(),
};
f.render_widget(Clear, area);
let p = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
format!(" kill {what}?"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(" y = yes any other key = no"),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(" confirm ")
.border_style(Style::default().fg(Color::Red)),
);
f.render_widget(p, area);
}
fn kv(k: &str, v: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {k:<10}"), Style::default().fg(Color::DarkGray)),
Span::raw(v.to_string()),
])
}
fn shorten(s: &str, max: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
return s.to_string();
}
let tail: String = chars[chars.len() - (max - 1)..].iter().collect();
format!("…{tail}")
}
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.split(v[1])[1]
}
#[cfg(test)]
mod tests {
use super::super::tmux_query::{Session, Window};
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn sample() -> App {
let snap = Snapshot {
sessions: vec![
Session {
name: "work".into(),
id: "$0".into(),
windows: 2,
attached: true,
..Default::default()
},
Session {
name: "scratch".into(),
id: "$1".into(),
windows: 1,
..Default::default()
},
],
windows: vec![
Window {
session: "work".into(),
index: 0,
name: "zsh".into(),
id: "@0".into(),
active: false,
panes: 2,
width: 100,
height: 30,
..Default::default()
},
Window {
session: "work".into(),
index: 1,
name: "editor".into(),
id: "@1".into(),
active: true,
panes: 1,
width: 100,
height: 30,
..Default::default()
},
Window {
session: "scratch".into(),
index: 0,
name: "zsh".into(),
id: "@2".into(),
active: true,
panes: 1,
..Default::default()
},
],
panes: vec![Pane {
session: "work".into(),
window: 1,
index: 0,
id: "%2".into(),
active: true,
pid: 113,
command: "nvim".into(),
path: "/home/x/proj".into(),
width: 100,
height: 30,
..Default::default()
}],
clients: vec![Client {
name: "c0".into(),
tty: "/dev/ttys9".into(),
session: "work".into(),
width: 100,
height: 30,
termname: "xterm".into(),
pid: 99,
}],
error: None,
};
let mut app = App {
snap,
socket: String::new(),
rows: Vec::new(),
list: ListState::default(),
history: VecDeque::from([1u64]),
last_refresh: Instant::now(),
refresh_every: Duration::from_millis(1000),
confirm_kill: false,
status: "test".into(),
quit: false,
};
app.rebuild_rows();
app.list.select(Some(0));
app
}
fn render(app: &mut App, w: u16, h: u16) -> String {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| ui(f, app)).unwrap();
term.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect()
}
#[test]
fn flattened_tree_has_a_row_per_session_and_window() {
let app = sample();
assert_eq!(app.rows.len(), 5); }
#[test]
fn renders_header_stats_and_tree() {
let mut app = sample();
let s = render(&mut app, 120, 40);
assert!(s.contains("ztmux dashboard"), "header title");
assert!(s.contains("2 sessions"), "session count");
assert!(s.contains("work") && s.contains("editor") && s.contains("scratch"));
}
#[test]
fn selecting_a_window_shows_its_panes() {
let mut app = sample();
app.list.select(Some(2)); let s = render(&mut app, 120, 40);
assert!(s.contains("nvim"), "pane command in detail table");
assert!(s.contains("113"), "pane pid in detail table");
}
#[test]
fn selecting_a_session_shows_its_clients() {
let mut app = sample();
app.list.select(Some(0)); let s = render(&mut app, 120, 40);
assert!(s.contains("clients"), "clients heading");
assert!(s.contains("xterm"), "client termname");
}
#[test]
fn navigation_wraps_around() {
let mut app = sample();
app.list.select(Some(0));
app.move_sel(-1);
assert_eq!(app.list.selected(), Some(4)); app.move_sel(1);
assert_eq!(app.list.selected(), Some(0)); }
#[test]
fn error_snapshot_renders_no_server_screen() {
let mut app = sample();
app.snap = Snapshot {
error: Some("no server running".into()),
..Default::default()
};
app.rebuild_rows();
let s = render(&mut app, 100, 30);
assert!(s.contains("no ztmux server reachable"));
}
}