use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Paragraph, Row, Table};
use ratatui::Frame;
use crate::app::{AppMode, ConnectionState, Model};
use crate::forward::{ForwardKey, ForwardKind, ForwardStatus};
use crate::ui::header;
const LOGO: &[&str] = &[
r" __ ____ __ ",
r" __________/ /_ / __/ ______/ / ",
r" / ___/ ___/ __ \/ /_| | /| / / __ / ",
r" (__ |__ ) / / / __/| |/ |/ / /_/ / ",
r"/____/____/_/ /_/_/ |__/|__/\__,_/ ",
];
const HEADER_STYLE: Style = Style::new()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD);
const SELECTED_STYLE: Style = Style::new()
.fg(Color::White)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD);
const SEP: &str = "────────────────────";
#[derive(Debug, Clone, PartialEq)]
pub enum DisplayRow {
Port(usize), LocalPort(usize), InactiveForward(u16), InactiveReverseForward(u16), Separator,
}
impl DisplayRow {
pub fn is_selectable(&self) -> bool {
matches!(
self,
DisplayRow::Port(_)
| DisplayRow::LocalPort(_)
| DisplayRow::InactiveForward(_)
| DisplayRow::InactiveReverseForward(_)
)
}
}
pub fn build_display_rows(model: &Model) -> Vec<DisplayRow> {
match model.mode {
AppMode::Forward => build_forward_rows(model),
AppMode::Reverse => build_reverse_rows(model),
}
}
fn build_forward_rows(model: &Model) -> Vec<DisplayRow> {
let scan_ports: std::collections::HashSet<u16> = model.ports.iter().map(|p| p.port).collect();
let mut forwarded = Vec::new();
let mut non_forwarded = Vec::new();
for (i, port) in model.ports.iter().enumerate() {
if model.forwards.contains_key(&ForwardKey::local(port.port)) {
forwarded.push((port.port, DisplayRow::Port(i)));
} else {
non_forwarded.push(DisplayRow::Port(i));
}
}
if model.show_inactive_forwards {
for (key, entry) in &model.forwards {
if key.kind == ForwardKind::Local
&& entry.status == ForwardStatus::Paused
&& !scan_ports.contains(&key.remote_port)
{
forwarded.push((
key.remote_port,
DisplayRow::InactiveForward(key.remote_port),
));
}
}
}
forwarded.sort_by(|(port_a, row_a), (port_b, row_b)| {
port_a.cmp(port_b).then_with(|| match (row_a, row_b) {
(DisplayRow::Port(i1), DisplayRow::Port(i2)) => i1.cmp(i2),
(DisplayRow::Port(_), DisplayRow::InactiveForward(_)) => std::cmp::Ordering::Less,
(DisplayRow::InactiveForward(_), DisplayRow::Port(_)) => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
})
});
let has_top = !forwarded.is_empty();
let mut rows = Vec::with_capacity(forwarded.len() + 1 + non_forwarded.len());
rows.extend(forwarded.into_iter().map(|(_, dr)| dr));
if has_top && !non_forwarded.is_empty() {
rows.push(DisplayRow::Separator);
}
rows.extend(non_forwarded);
rows
}
fn build_reverse_rows(model: &Model) -> Vec<DisplayRow> {
let local_scan_ports: std::collections::HashSet<u16> =
model.local_ports.iter().map(|p| p.port).collect();
let reverse_by_local: std::collections::HashMap<u16, ForwardKey> = model
.forwards
.iter()
.filter(|(k, _)| k.kind == ForwardKind::Reverse)
.map(|(k, e)| (e.local_port, *k))
.collect();
let mut reverse_forwarded = Vec::new();
let mut non_reverse_forwarded = Vec::new();
for (i, port) in model.local_ports.iter().enumerate() {
if reverse_by_local.contains_key(&port.port) {
reverse_forwarded.push((port.port, DisplayRow::LocalPort(i)));
} else {
non_reverse_forwarded.push(DisplayRow::LocalPort(i));
}
}
if model.show_inactive_forwards {
for (key, entry) in &model.forwards {
if key.kind == ForwardKind::Reverse && !local_scan_ports.contains(&entry.local_port) {
reverse_forwarded.push((
entry.local_port,
DisplayRow::InactiveReverseForward(key.remote_port),
));
}
}
}
reverse_forwarded.sort_by_key(|(port, _)| *port);
let has_top = !reverse_forwarded.is_empty();
let mut rows = Vec::with_capacity(reverse_forwarded.len() + 1 + non_reverse_forwarded.len());
rows.extend(reverse_forwarded.into_iter().map(|(_, dr)| dr));
if has_top && !non_reverse_forwarded.is_empty() {
rows.push(DisplayRow::Separator);
}
rows.extend(non_reverse_forwarded);
rows
}
pub fn render(model: &mut Model, frame: &mut Frame, area: Rect) {
let title = header::build_title(model);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray))
.title(title);
let show_splash = match model.mode {
AppMode::Forward => model.ports.is_empty() || model.started_at.elapsed().as_secs() < 1,
AppMode::Reverse => {
model.local_ports.is_empty() || model.started_at.elapsed().as_secs() < 1
}
};
if show_splash {
render_splash(model, frame, area, block);
return;
}
let header_row = Row::new(["FWD", "PORT", "PROTO", "PID", "COMMAND"]).style(HEADER_STYLE);
let widths = [
Constraint::Length(9),
Constraint::Length(8),
Constraint::Length(7),
Constraint::Length(9),
Constraint::Min(20),
];
let display_rows = build_display_rows(model);
let inactive_style = Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM);
let rows: Vec<Row> = display_rows
.iter()
.map(|dr| match dr {
DisplayRow::Port(i) => {
let port = &model.ports[*i];
let fwd_cell = format_local_fwd(model, port.port);
make_port_row(fwd_cell, port)
}
DisplayRow::LocalPort(i) => {
let port = &model.local_ports[*i];
let fwd_cell = format_reverse_fwd(model, port.port);
make_port_row(fwd_cell, port)
}
DisplayRow::InactiveForward(remote_port) => {
let local_port = model
.forwards
.get(&ForwardKey::local(*remote_port))
.map_or(*remote_port, |e| e.local_port);
Row::new([
format!("||:{}", local_port),
remote_port.to_string(),
"-".to_string(),
"-".to_string(),
"(inactive)".to_string(),
])
.style(inactive_style)
}
DisplayRow::InactiveReverseForward(remote_port) => {
let entry = model.forwards.get(&ForwardKey::reverse(*remote_port));
let local_port = entry.map_or(*remote_port, |e| e.local_port);
Row::new([
format!("||<-:{}", remote_port),
local_port.to_string(),
"-".to_string(),
"-".to_string(),
"(inactive)".to_string(),
])
.style(inactive_style)
}
DisplayRow::Separator => {
Row::new([SEP, SEP, SEP, SEP, SEP]).style(Style::default().fg(Color::DarkGray))
}
})
.collect();
let inner = block.inner(area);
let table = Table::new(rows, widths)
.block(block)
.header(header_row)
.row_highlight_style(SELECTED_STYLE)
.highlight_symbol("▶ ")
.column_spacing(2);
if !display_rows.is_empty() {
model.table_state.select(Some(model.selected_index));
}
frame.render_stateful_widget(table, area, &mut model.table_state);
model.table_content_area = Some(Rect {
x: inner.x,
y: inner.y + 1, width: inner.width,
height: inner.height.saturating_sub(1),
});
}
fn render_splash(model: &Model, frame: &mut Frame, area: Rect, block: Block) {
let inner = block.inner(area);
frame.render_widget(block, area);
let logo_height = LOGO.len() as u16;
let status_line = match model.connection_state {
ConnectionState::Connecting => "Connecting...",
ConnectionState::Connected => "Waiting for ports...",
ConnectionState::Reconnecting => "Reconnecting...",
ConnectionState::Disconnected => "Disconnected",
};
let content_height = logo_height + 2;
let vertical = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(content_height),
Constraint::Fill(1),
])
.split(inner);
let content_area = vertical[1];
let mut lines: Vec<Line> = LOGO
.iter()
.map(|line| Line::from(Span::styled(*line, Style::default().fg(Color::Cyan))))
.collect();
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
status_line,
Style::default().fg(Color::DarkGray),
)));
let paragraph = Paragraph::new(lines).centered();
frame.render_widget(paragraph, content_area);
}
fn protocol_str(protocol: &sshfwd_common::types::Protocol) -> &'static str {
match protocol {
sshfwd_common::types::Protocol::Tcp => "tcp",
sshfwd_common::types::Protocol::Tcp6 => "tcp6",
}
}
fn make_port_row(
fwd_cell: (String, Option<Style>),
port: &sshfwd_common::types::ListeningPort,
) -> Row<'static> {
let proto = protocol_str(&port.protocol);
let (pid, cmd) = match &port.process {
Some(p) => (p.pid.to_string(), p.cmdline.clone()),
None => ("-".to_string(), "-".to_string()),
};
Row::new([
fwd_cell.0,
port.port.to_string(),
proto.to_string(),
pid,
cmd,
])
.style(fwd_cell.1.unwrap_or_default())
}
fn format_local_fwd(model: &Model, remote_port: u16) -> (String, Option<Style>) {
match model.forwards.get(&ForwardKey::local(remote_port)) {
Some(entry) => match &entry.status {
ForwardStatus::Active => (
format!("->:{}", entry.local_port),
Some(Style::default().fg(Color::Green)),
),
ForwardStatus::Paused => (
format!("||:{}", entry.local_port),
Some(Style::default().fg(Color::Yellow)),
),
ForwardStatus::Starting => {
("...".to_string(), Some(Style::default().fg(Color::Yellow)))
}
},
None => (String::new(), None),
}
}
fn format_reverse_fwd(model: &Model, local_port: u16) -> (String, Option<Style>) {
let found = model
.forwards
.iter()
.find(|(k, e)| k.kind == ForwardKind::Reverse && e.local_port == local_port);
match found {
Some((key, entry)) => match &entry.status {
ForwardStatus::Active => (
format!("<-:{}", key.remote_port),
Some(Style::default().fg(Color::Green)),
),
ForwardStatus::Paused => (
format!("||<-:{}", key.remote_port),
Some(Style::default().fg(Color::Yellow)),
),
ForwardStatus::Starting => {
("...".to_string(), Some(Style::default().fg(Color::Yellow)))
}
},
None => (String::new(), None),
}
}