use std::time::Instant;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use unicode_width::UnicodeWidthStr;
use super::design;
use super::host_list;
use super::theme;
use crate::app::{App, TunnelSortMode};
use crate::tunnel::{TunnelRule, TunnelType, format_uptime};
const TOP_BAR_HEIGHT: u16 = 3;
const GAP: &str = " ";
const HIGHLIGHT_W: usize = 1;
const MARKER_W: usize = 1;
const STATUS_W: usize = 2;
const ALIAS_MIN: usize = 8;
const FORWARD_MIN: usize = 12;
const SPEED_W: usize = 10;
const UPTIME_W: usize = 7;
const LAST_W: usize = 6;
struct TunnelRow {
alias: String,
rule: TunnelRule,
is_active: bool,
started_at: Option<Instant>,
current_bps: u64,
throughput_ready: bool,
last_text: String,
}
pub(crate) fn visible_pairs(app: &App) -> Vec<(String, TunnelRule)> {
let query = app
.search
.query
.as_deref()
.map(|q| q.to_lowercase())
.filter(|q| !q.is_empty());
let mut pairs: Vec<(String, TunnelRule)> = Vec::new();
for host in &app.hosts_state.list {
let rules = app
.hosts_state
.ssh_config
.find_tunnel_directives(&host.alias);
for rule in rules {
if let Some(ref q) = query {
let alias_match = host.alias.to_lowercase().contains(q);
let forward_match = forward_label(&rule).to_lowercase().contains(q);
if !alias_match && !forward_match {
continue;
}
}
pairs.push((host.alias.clone(), rule));
}
}
sort_pairs(&mut pairs, app);
pairs
}
fn sort_pairs(pairs: &mut [(String, TunnelRule)], app: &App) {
match app.tunnels.sort_mode {
TunnelSortMode::MostRecent => {
pairs.sort_by(|a, b| {
let a_started = app.tunnels.active.get(&a.0).map(|t| t.started_at);
let b_started = app.tunnels.active.get(&b.0).map(|t| t.started_at);
match (a_started, b_started) {
(Some(ax), Some(bx)) => bx.cmp(&ax),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => {
let ts_a = app.history.last_connected(&a.0);
let ts_b = app.history.last_connected(&b.0);
ts_b.cmp(&ts_a)
.then_with(|| a.0.to_ascii_lowercase().cmp(&b.0.to_ascii_lowercase()))
}
}
});
}
TunnelSortMode::AlphaHostname => {
pairs.sort_by_key(|a| a.0.to_ascii_lowercase());
}
}
}
fn build_rows(app: &App) -> Vec<TunnelRow> {
visible_pairs(app)
.into_iter()
.map(|(alias, rule)| {
let runtime = app.tunnels.active.get(&alias);
let demo_snapshot = if app.demo_mode {
app.tunnels.demo_live_snapshots.get(&alias)
} else {
None
};
let is_active = runtime.is_some() || demo_snapshot.is_some();
let started_at = runtime.map(|a| a.started_at).or_else(|| {
demo_snapshot
.map(|s| Instant::now() - std::time::Duration::from_secs(s.uptime_secs))
});
let (current_bps, throughput_ready) = if let Some(s) = demo_snapshot {
(
s.current_rx_bps.saturating_add(s.current_tx_bps),
s.throughput_ready,
)
} else {
runtime
.map(|t| {
(
t.live.current_rx_bps.saturating_add(t.live.current_tx_bps),
t.live.last_throughput_at.is_some(),
)
})
.unwrap_or((0, false))
};
let last_text = if is_active {
"now".to_string()
} else {
let ts = app.history.last_connected(&alias);
crate::history::ConnectionHistory::format_time_ago(ts)
};
TunnelRow {
is_active,
started_at,
alias,
rule,
current_bps,
throughput_ready,
last_text,
}
})
.collect()
}
fn forward_label(rule: &TunnelRule) -> String {
match rule.tunnel_type {
TunnelType::Dynamic => format!("{} \u{2192} any", format_bind(rule)),
TunnelType::Local => format!(
"{} \u{2192} {}",
format_bind(rule),
format_remote(&rule.remote_host, rule.remote_port)
),
TunnelType::Remote => format!(
"{} \u{2190} {}",
format_bind(rule),
format_remote(&rule.remote_host, rule.remote_port)
),
}
}
fn format_bind(rule: &TunnelRule) -> String {
if rule.bind_address.is_empty() {
rule.bind_port.to_string()
} else if rule.bind_address.contains(':') {
format!("[{}]:{}", rule.bind_address, rule.bind_port)
} else {
format!("{}:{}", rule.bind_address, rule.bind_port)
}
}
fn format_remote(host: &str, port: u16) -> String {
if host.contains(':') {
format!("[{}]:{}", host, port)
} else {
format!("{}:{}", host, port)
}
}
fn render_speed_cell(row: &TunnelRow) -> (String, ratatui::style::Style) {
if !row.is_active {
return ("\u{2014}".to_string(), theme::muted());
}
if row.current_bps > 0 {
return (
super::tunnels_format::format_bps(row.current_bps),
theme::accent_bold(),
);
}
let label = if row.throughput_ready {
"idle"
} else {
"sampling\u{2026}"
};
(label.to_string(), theme::muted())
}
fn render_uptime_cell(row: &TunnelRow, now: Instant) -> String {
if !row.is_active {
return String::new();
}
match row.started_at {
Some(start) => format_uptime(now.saturating_duration_since(start)),
None => String::new(),
}
}
#[allow(dead_code)]
fn render_right_text(row: &TunnelRow, now: Instant) -> Vec<Span<'static>> {
if !row.is_active {
return vec![Span::styled("\u{2014}", theme::muted())];
}
let uptime = match row.started_at {
Some(start) => format_uptime(now.saturating_duration_since(start)),
None => String::new(),
};
if row.current_bps > 0 {
let bps = super::tunnels_format::format_bps(row.current_bps);
vec![
Span::styled(bps, theme::accent_bold()),
Span::raw(" "),
Span::styled(uptime, theme::muted()),
]
} else {
let lhs = if row.throughput_ready {
"idle"
} else {
"sampling\u{2026}"
};
vec![
Span::styled(lhs.to_string(), theme::muted()),
Span::raw(" "),
Span::styled(uptime, theme::muted()),
]
}
}
struct Columns {
alias: usize,
forward: usize,
flex_gap: usize,
last: usize,
}
fn compute_columns(rows: &[TunnelRow], content_w: usize) -> Columns {
let alias_content = rows.iter().map(|r| r.alias.width()).max().unwrap_or(0);
let alias = alias_content.max(ALIAS_MIN);
let forward_content = rows
.iter()
.map(|r| forward_label(&r.rule).width())
.max()
.unwrap_or(0);
let forward = forward_content.max(FORWARD_MIN);
let gap = GAP.width();
let min_flex = gap;
let last_content = rows
.iter()
.map(|r| r.last_text.width())
.max()
.unwrap_or(0)
.max("LAST".width()); let last = last_content.min(LAST_W);
let fixed = HIGHLIGHT_W
+ MARKER_W
+ STATUS_W
+ alias
+ gap
+ forward
+ min_flex
+ SPEED_W
+ gap
+ UPTIME_W
+ gap
+ last;
let (forward_final, flex_gap) = if fixed > content_w {
let excess = fixed - content_w;
let shrunk = forward.saturating_sub(excess).max(FORWARD_MIN);
(shrunk, min_flex)
} else {
let extra = content_w - fixed;
(forward, min_flex + extra)
};
Columns {
alias,
forward: forward_final,
flex_gap,
last,
}
}
fn pad(s: &str, w: usize) -> String {
let cur = s.width();
if cur >= w {
s.to_string()
} else {
format!("{}{}", s, " ".repeat(w - cur))
}
}
fn render_header(frame: &mut Frame, area: Rect, cols: &Columns, sort_mode: TunnelSortMode) {
let style = theme::bold();
let gap = " ".repeat(GAP.width());
let flex = " ".repeat(cols.flex_gap);
let alpha_sort = matches!(sort_mode, TunnelSortMode::AlphaHostname);
let recent_sort = matches!(sort_mode, TunnelSortMode::MostRecent);
let alias_label = if alpha_sort { "NAME \u{25BE}" } else { "NAME" };
let leading_pad = " ".repeat(HIGHLIGHT_W + MARKER_W + STATUS_W);
let mut spans = vec![
Span::styled(leading_pad, style),
Span::styled(
format!("{:<width$}", alias_label, width = cols.alias),
style,
),
];
spans.push(Span::raw(gap.clone()));
spans.push(Span::styled(
format!("{:<width$}", "FORWARD", width = cols.forward),
style,
));
spans.push(Span::raw(flex));
spans.push(Span::styled(
format!("{:>width$}", "SPEED", width = SPEED_W),
style,
));
spans.push(Span::raw(gap.clone()));
spans.push(Span::styled(
format!("{:>width$}", "UPTIME", width = UPTIME_W),
style,
));
spans.push(Span::raw(gap));
spans.push(Span::styled(
format!("{:>width$}", "LAST", width = cols.last),
style,
));
if recent_sort {
spans.push(Span::styled("\u{25BE}", style));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_row<'a>(
row: &'a TunnelRow,
cols: &Columns,
now: Instant,
spinner_tick: u64,
) -> ListItem<'a> {
let (status_glyph, status_style) = if row.is_active {
("\u{25CF}", theme::online_dot_pulsing(spinner_tick))
} else {
("\u{25CB}", theme::muted())
};
let (speed_text, speed_style) = render_speed_cell(row);
let uptime_text = render_uptime_cell(row, now);
let last_display = if row.last_text.is_empty() {
"\u{2014}".to_string()
} else {
row.last_text.clone()
};
let last_style = if row.is_active {
theme::online_dot_pulsing(spinner_tick)
} else {
theme::muted()
};
let spans: Vec<Span<'static>> = vec![
Span::raw(" ".repeat(MARKER_W)),
Span::styled(format!("{} ", status_glyph), status_style),
Span::styled(pad(&row.alias, cols.alias), theme::bold()),
Span::raw(GAP),
Span::styled(pad(&forward_label(&row.rule), cols.forward), theme::muted()),
Span::raw(" ".repeat(cols.flex_gap)),
Span::styled(
format!("{:>width$}", speed_text, width = SPEED_W),
speed_style,
),
Span::raw(GAP),
Span::styled(
format!("{:>width$}", uptime_text, width = UPTIME_W),
theme::muted(),
),
Span::raw(GAP),
Span::styled(
format!("{:>width$}", last_display, width = cols.last),
last_style,
),
];
ListItem::new(Line::from(spans))
}
pub fn render(frame: &mut Frame, app: &mut App, anim: &mut crate::animation::AnimationState) {
let spinner_tick = anim.spinner_tick;
let area = frame.area();
let search_active = app.search.query.is_some();
let search_bar_h = if search_active { 1 } else { 0 };
let [top_bar_area, body_area, search_bar_area, footer_area] = Layout::vertical([
Constraint::Length(TOP_BAR_HEIGHT),
Constraint::Min(0),
Constraint::Length(search_bar_h),
Constraint::Length(1),
])
.areas(area);
render_top_bar(frame, app, top_bar_area);
let rows = build_rows(app);
let row_count = rows.len();
if search_active {
render_search_bar(frame, app, search_bar_area, row_count);
}
let sel = app.ui.tunnels_overview_state.selected();
let new_sel = match sel {
Some(i) if i < row_count => Some(i),
_ if row_count > 0 => Some(0),
_ => None,
};
if new_sel != sel {
app.ui.tunnels_overview_state.select(new_sel);
}
let target_panel_height = panel_stretch_height(body_area, &rows);
let panel_visible_target = is_selected_tunnel_active(app, &rows);
anim.note_tunnel_panel_target(panel_visible_target);
let panel_progress = anim.tunnel_panel_anim_progress();
let (panel_visible, panel_height) = match panel_progress {
Some(p) => {
let h = ((target_panel_height as f32 * p).round() as u16).max(1);
(true, h)
}
None => {
if panel_visible_target {
(true, target_panel_height)
} else {
(false, 0)
}
}
};
let (list_block_area, dashboard_area) = partition_body(body_area, panel_visible, panel_height);
let update_title = app.update.available.as_ref().map(|ver| {
let label = host_list::build_update_label(
ver,
app.update.headline.as_deref(),
app.update.hint,
list_block_area.width,
);
Line::from(Span::styled(label, theme::update_badge()))
});
let url_label = Line::from(Span::styled(" getpurple.sh ", theme::muted()));
let mut block = if search_active {
let total: usize = app
.hosts_state
.list
.iter()
.map(|h| {
app.hosts_state
.ssh_config
.find_tunnel_directives(&h.alias)
.len()
})
.sum();
let title = Line::from(vec![Span::styled(
format!(" search: {}/{} ", row_count, total),
theme::bold(),
)]);
design::search_block_line(title).title_bottom(url_label.right_aligned())
} else {
design::main_block_line(Line::default()).title_bottom(url_label.right_aligned())
};
if let Some(update) = update_title.as_ref() {
block = block.title_top(update.clone().right_aligned());
}
let block_inner = block.inner(list_block_area);
frame.render_widget(block, list_block_area);
if rows.is_empty() {
design::render_empty(frame, block_inner, "No tunnels.");
render_footer(frame, footer_area, app, &rows);
return;
}
let inner = Rect {
x: block_inner.x.saturating_add(1),
y: block_inner.y,
width: block_inner.width.saturating_sub(2),
height: block_inner.height,
};
let [header_area, underline_area, list_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(inner);
let content_w = (inner.width as usize).saturating_sub(1);
let cols = compute_columns(&rows, content_w);
render_header(frame, header_area, &cols, app.tunnels.sort_mode);
frame.render_widget(
Paragraph::new(Span::styled(
"\u{2500}".repeat(underline_area.width as usize),
theme::muted(),
)),
underline_area,
);
let now = Instant::now();
let items: Vec<ListItem> = rows
.iter()
.map(|r| render_row(r, &cols, now, spinner_tick))
.collect();
let list = List::new(items)
.highlight_style(theme::selected_row())
.highlight_symbol(design::HOST_HIGHLIGHT);
frame.render_stateful_widget(list, list_area, &mut app.ui.tunnels_overview_state);
if let Some(dash) = dashboard_area {
if panel_visible_target {
super::tunnels_detail::render(frame, app, dash, spinner_tick);
} else {
let block = design::main_block_line(Line::default());
frame.render_widget(block, dash);
}
}
render_footer(frame, footer_area, app, &rows);
}
const LIST_MIN_HEIGHT: u16 = 4;
const LIST_CHROME_ROWS: u16 = 4;
const PANEL_GAP: u16 = 0;
fn panel_stretch_height(body_area: Rect, rows: &[TunnelRow]) -> u16 {
let needed_list = (rows.len() as u16)
.saturating_add(LIST_CHROME_ROWS)
.max(LIST_MIN_HEIGHT);
body_area
.height
.saturating_sub(needed_list)
.saturating_sub(PANEL_GAP)
}
fn partition_body(area: Rect, panel_visible: bool, panel_height: u16) -> (Rect, Option<Rect>) {
use super::tunnels_detail::DASHBOARD_MIN_BODY_HEIGHT;
if !panel_visible || area.height < DASHBOARD_MIN_BODY_HEIGHT {
return (area, None);
}
let height = panel_height.min(area.height.saturating_sub(LIST_MIN_HEIGHT + PANEL_GAP));
if height < 3 {
return (area, None);
}
let [list_area, _gap, dashboard] = Layout::vertical([
Constraint::Min(LIST_MIN_HEIGHT),
Constraint::Length(PANEL_GAP),
Constraint::Length(height),
])
.areas(area);
(list_area, Some(dashboard))
}
fn is_selected_tunnel_active(app: &App, rows: &[TunnelRow]) -> bool {
let sel = app.ui.tunnels_overview_state.selected();
let alias = match sel.and_then(|i| rows.get(i)).map(|r| r.alias.clone()) {
Some(a) => a,
None => return false,
};
if app.demo_mode {
return app.tunnels.demo_live_snapshots.contains_key(&alias);
}
app.tunnels
.active
.get(&alias)
.map(|t| t.live.last_exit.is_none())
.unwrap_or(false)
}
fn render_top_bar(frame: &mut Frame, app: &App, area: Rect) {
let block = design::main_block_line(Line::default());
let inner = block.inner(area);
frame.render_widget(block, area);
let content_area = Rect::new(
inner.x.saturating_add(1),
inner.y,
inner.width.saturating_sub(1),
1,
);
let line = Line::from(host_list::top_bar_spans(app));
frame.render_widget(Paragraph::new(line), content_area);
}
fn render_search_bar(frame: &mut Frame, app: &App, area: Rect, visible_count: usize) {
let query = app.search.query.as_deref().unwrap_or("");
let total: usize = app
.hosts_state
.list
.iter()
.map(|h| {
app.hosts_state
.ssh_config
.find_tunnel_directives(&h.alias)
.len()
})
.sum();
let match_info = if query.is_empty() {
String::new()
} else {
format!(" ({} of {})", visible_count, total)
};
let line = Line::from(vec![
Span::styled(" / ", theme::brand_badge()),
Span::raw(" "),
Span::raw(query.to_string()),
Span::styled("_", theme::accent()),
Span::styled(match_info, theme::muted()),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn render_footer(frame: &mut Frame, area: Rect, app: &mut App, rows: &[TunnelRow]) {
if app.tunnels.pending_delete.is_some() {
let mut spans = vec![Span::styled(" Remove tunnel? ", theme::bold())];
spans.extend(design::confirm_footer_destructive("delete", "keep").into_spans());
super::render_footer_with_status(frame, area, spans, app);
return;
}
let row_active = app
.ui
.tunnels_overview_state
.selected()
.and_then(|i| rows.get(i))
.map(|r| r.is_active)
.unwrap_or(false);
let primary_label = if row_active { " stop " } else { " start " };
let spans = design::Footer::new()
.primary("Enter", primary_label)
.action("/", " search ")
.action("s", " sort ")
.action(":", " jump ")
.into_spans();
super::render_footer_with_help(frame, area, spans, app);
}
#[cfg(test)]
mod tests {
use super::super::tunnels_detail::DASHBOARD_MIN_BODY_HEIGHT;
use super::*;
fn area(width: u16, height: u16) -> Rect {
Rect::new(0, 0, width, height)
}
fn make_row(alias: &str) -> TunnelRow {
TunnelRow {
alias: alias.to_string(),
rule: TunnelRule {
tunnel_type: TunnelType::Dynamic,
bind_address: String::new(),
bind_port: 8080,
remote_host: String::new(),
remote_port: 0,
},
last_text: "now".to_string(),
is_active: true,
started_at: None,
current_bps: 0,
throughput_ready: false,
}
}
#[test]
fn partition_skips_dashboard_when_body_short() {
let body = area(120, DASHBOARD_MIN_BODY_HEIGHT - 1);
let rows = vec![make_row("a")];
let target = panel_stretch_height(body, &rows);
let (list, dash) = partition_body(body, true, target);
assert_eq!(list.height, body.height);
assert!(dash.is_none());
}
#[test]
fn partition_skips_dashboard_when_panel_invisible() {
let (list, dash) = partition_body(area(120, 30), false, 25);
assert_eq!(list.height, 30);
assert!(dash.is_none());
}
#[test]
fn stretch_panel_fills_remaining_body() {
let body = area(120, 60);
let rows = vec![make_row("a"), make_row("b"), make_row("c")];
let target = panel_stretch_height(body, &rows);
let expected_panel = 60 - 3 - LIST_CHROME_ROWS - PANEL_GAP;
assert_eq!(target, expected_panel);
let (list, dash) = partition_body(body, true, target);
let dash = dash.expect("dashboard rendered when tall");
assert_eq!(list.height + dash.height + PANEL_GAP, 60);
assert!(list.height >= LIST_MIN_HEIGHT);
}
#[test]
fn partition_renders_dashboard_at_threshold() {
let body = area(
120,
(DASHBOARD_MIN_BODY_HEIGHT + LIST_CHROME_ROWS + PANEL_GAP).max(13),
);
let rows = vec![make_row("a")];
let target = panel_stretch_height(body, &rows);
let (list, dash) = partition_body(body, true, target);
let dash = dash.expect("dashboard rendered at threshold");
assert_eq!(list.height + dash.height + PANEL_GAP, body.height);
assert!(list.height >= LIST_MIN_HEIGHT);
}
#[test]
fn forward_label_dynamic_uses_any_remote() {
let r = TunnelRule {
tunnel_type: TunnelType::Dynamic,
bind_address: String::new(),
bind_port: 8080,
remote_host: String::new(),
remote_port: 0,
};
assert_eq!(forward_label(&r), "8080 \u{2192} any");
}
#[test]
fn forward_label_local_uses_right_arrow() {
let r = TunnelRule {
tunnel_type: TunnelType::Local,
bind_address: String::new(),
bind_port: 5432,
remote_host: "10.40.0.20".to_string(),
remote_port: 5432,
};
assert!(forward_label(&r).contains('\u{2192}'));
}
#[test]
fn forward_label_remote_uses_left_arrow() {
let r = TunnelRule {
tunnel_type: TunnelType::Remote,
bind_address: String::new(),
bind_port: 9000,
remote_host: "10.40.0.20".to_string(),
remote_port: 9000,
};
assert!(forward_label(&r).contains('\u{2190}'));
}
}