use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Padding, Paragraph};
use unicode_width::UnicodeWidthStr;
use crate::app::{App, PingStatus};
use crate::history::ConnectionHistory;
use crate::ssh_keys::SshKeyInfo;
use crate::ui::{self, design, theme};
use super::card_title;
struct LinkedHostRow<'a> {
alias: &'a str,
hostname: String,
status: Option<PingStatus>,
last_connected: u64,
}
pub(super) fn render_linked_hosts(frame: &mut Frame, app: &App, key: &SshKeyInfo, area: Rect) {
let n = key.linked_hosts.len();
let count_text = if n == 0 {
"none".to_string()
} else {
n.to_string()
};
let block = design::main_block_line(card_title("LINKED HOSTS", Some(&count_text)))
.padding(Padding::new(1, 1, 0, 0));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 || n == 0 {
return;
}
let mut rows: Vec<LinkedHostRow> = key
.linked_hosts
.iter()
.map(|alias| {
let hostname = app
.hosts_state
.list()
.iter()
.find(|h| h.alias == *alias)
.map(|h| h.hostname.clone())
.unwrap_or_default();
let status = app.ping.status_of(alias).cloned();
let last_connected = app
.history
.entry(alias)
.map(|e| e.last_connected)
.unwrap_or(0);
LinkedHostRow {
alias,
hostname,
status,
last_connected,
}
})
.collect();
let (mut connected, mut never): (Vec<_>, Vec<_>) =
rows.drain(..).partition(|r| r.last_connected > 0);
connected.sort_by_key(|r| std::cmp::Reverse(r.last_connected));
never.sort_by_key(|r| r.alias.to_ascii_lowercase());
const LAST_SEEN_W: usize = 10;
const GLYPH_COL: usize = 2;
const GAP: usize = 2;
let alias_w = key
.linked_hosts
.iter()
.map(|a| UnicodeWidthStr::width(a.as_str()))
.max()
.unwrap_or(0)
.clamp(8, 26);
let fixed = GLYPH_COL + alias_w + GAP + GAP + LAST_SEEN_W;
let hostname_w = (inner.width as usize).saturating_sub(fixed).max(8);
let inner_h = inner.height as usize;
let want_divider = !connected.is_empty() && !never.is_empty();
let capacity_for_rows = if want_divider {
inner_h.saturating_sub(1)
} else {
inner_h
};
let total = connected.len() + never.len();
let need_overflow = total > capacity_for_rows;
let visible_cap = if need_overflow {
capacity_for_rows.saturating_sub(1)
} else {
capacity_for_rows
};
let mut lines: Vec<Line> = Vec::with_capacity(inner_h);
let mut shown = 0usize;
for r in &connected {
if shown >= visible_cap {
break;
}
lines.push(render_host_row(r, alias_w, hostname_w, LAST_SEEN_W, false));
shown += 1;
}
let connected_shown = shown;
if want_divider && shown < visible_cap {
lines.push(linked_hosts_divider(inner.width as usize));
}
for r in &never {
if shown >= visible_cap {
break;
}
lines.push(render_host_row(r, alias_w, hostname_w, LAST_SEEN_W, true));
shown += 1;
}
let unseen = total.saturating_sub(connected_shown + never.len().min(shown - connected_shown));
if need_overflow && unseen > 0 {
lines.push(Line::from(Span::styled(
format!("... {} more", unseen),
theme::muted(),
)));
}
frame.render_widget(Paragraph::new(lines), inner);
}
fn render_host_row(
row: &LinkedHostRow,
alias_w: usize,
hostname_w: usize,
last_seen_w: usize,
never_connected: bool,
) -> Line<'static> {
use ratatui::style::Modifier;
let (glyph, glyph_style) = match &row.status {
Some(PingStatus::Reachable { .. }) => (design::ICON_ONLINE, theme::online_dot()),
Some(PingStatus::Slow { .. }) => (design::ICON_SLOW, theme::warning()),
Some(PingStatus::Unreachable) => (design::ICON_ERROR, theme::error()),
Some(PingStatus::Checking) => (design::ICON_PENDING, theme::muted()),
Some(PingStatus::Skipped) | None => (design::ICON_PENDING, theme::muted()),
};
let alias_style = if never_connected {
theme::muted()
} else {
theme::bold()
};
let alias_padded = pad_to_width(row.alias, alias_w);
let hostname_padded = pad_to_width(&row.hostname, hostname_w);
let last_seen_text = if row.last_connected == 0 {
"\u{2014}".to_string()
} else {
let ago = ConnectionHistory::format_time_ago(row.last_connected);
if ago.is_empty() {
"now".to_string()
} else {
format!("{} ago", ago)
}
};
let last_seen_padded = format!("{:>width$}", last_seen_text, width = last_seen_w);
let last_seen_style = if row.last_connected == 0 {
theme::muted()
} else {
theme::muted().add_modifier(Modifier::DIM)
};
Line::from(vec![
Span::styled(glyph.to_string(), glyph_style),
Span::raw(" "),
Span::styled(alias_padded, alias_style),
Span::raw(" "),
Span::styled(hostname_padded, theme::muted()),
Span::raw(" "),
Span::styled(last_seen_padded, last_seen_style),
])
}
fn linked_hosts_divider(width: usize) -> Line<'static> {
let label = " never connected ";
let total_dashes = width.saturating_sub(label.chars().count());
let left = total_dashes / 2;
let right = total_dashes.saturating_sub(left);
Line::from(vec![
Span::styled("\u{2500}".repeat(left), theme::muted()),
Span::styled(label.to_string(), theme::muted()),
Span::styled("\u{2500}".repeat(right), theme::muted()),
])
}
fn pad_to_width(s: &str, width: usize) -> String {
let display_w = UnicodeWidthStr::width(s);
if display_w == width {
return s.to_string();
}
if display_w < width {
let pad = width - display_w;
let mut out = String::with_capacity(s.len() + pad);
out.push_str(s);
for _ in 0..pad {
out.push(' ');
}
return out;
}
ui::truncate(s, width)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pad_to_width_pads_short_strings() {
assert_eq!(pad_to_width("abc", 6), "abc ");
}
#[test]
fn pad_to_width_truncates_with_ellipsis() {
assert_eq!(pad_to_width("abcdefghij", 6), "abcde\u{2026}");
}
#[test]
fn pad_to_width_returns_input_when_width_matches_exactly() {
assert_eq!(pad_to_width("abc", 3), "abc");
}
}