use ratatui::Frame;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem};
use unicode_width::UnicodeWidthStr;
use super::design;
use super::theme;
use crate::app::App;
use crate::handler::key_push_picker::{is_vault_host, pickable_hosts};
use crate::ssh_config::model::HostEntry;
const MAX_VISIBLE_ROWS: u16 = 16;
pub fn render(frame: &mut Frame, app: &mut App, key_index: usize) {
let hosts: Vec<&HostEntry> = pickable_hosts(app).collect();
let selected_count = app.keys.push().selected.len();
let total = hosts.len();
let eligible_total = hosts.iter().filter(|h| !is_vault_host(h)).count();
let key_label = app
.keys
.list()
.get(key_index)
.map(|k| format!("{}.pub", k.name))
.unwrap_or_else(|| "key".to_string());
let title = if selected_count == 0 {
crate::messages::key_push_picker_title_eligible(&key_label, eligible_total, total)
} else {
crate::messages::key_push_picker_title_selected(
&key_label,
selected_count,
total,
eligible_total,
)
};
let list_rows = (hosts.len() as u16).clamp(1, MAX_VISIBLE_ROWS);
let total_height = 2 + list_rows;
let term_w = frame.area().width;
let term_h = frame.area().height;
let target_w = design::PICKER_MIN_W.max(term_w * 60 / 100);
let overlay_width = target_w
.min(design::PICKER_MAX_W)
.min(term_w.saturating_sub(4));
let height = total_height
.min(design::PICKER_MAX_H)
.min(term_h.saturating_sub(3));
let area = super::centered_rect_fixed(overlay_width, height, frame.area());
frame.render_widget(Clear, area);
let block = design::overlay_block(&title);
let inner = block.inner(area);
frame.render_widget(block, area);
if hosts.is_empty() {
design::render_empty(frame, inner, crate::messages::KEY_PUSH_NO_HOSTS);
} else {
let content_w = inner.width as usize;
let items: Vec<ListItem> = hosts
.iter()
.map(|h| build_row(h, &app.keys.push().selected, content_w))
.collect();
let sel = app.keys.push().list_state.selected();
let new_sel = match sel {
Some(i) if i < hosts.len() => Some(i),
_ => Some(0),
};
if new_sel != sel {
app.keys.push_mut().list_state.select(new_sel);
}
let list = List::new(items).highlight_style(theme::selected_row());
frame.render_stateful_widget(list, inner, &mut app.keys.push_mut().list_state);
}
let footer_area = design::render_overlay_footer(frame, area);
use crate::messages::footer as fl;
design::Footer::new()
.primary("Enter", fl::ENTER_CONFIRM)
.action("Space", fl::SPACE_TOGGLE)
.action("a", fl::ACTION_ALL)
.action("Esc", fl::ESC_CANCEL)
.render_with_status(frame, footer_area, app);
}
fn build_row_spans(
host: &HostEntry,
selected: &std::collections::HashSet<String>,
content_w: usize,
) -> Vec<Span<'static>> {
let is_vault = is_vault_host(host);
let is_selected = selected.contains(&host.alias);
let checkbox = if is_vault {
"[-]"
} else if is_selected {
"[x]"
} else {
"[ ]"
};
let checkbox_style = if is_vault {
theme::muted()
} else if is_selected {
theme::accent_bold()
} else {
theme::muted()
};
let alias_style = if is_vault {
theme::muted()
} else {
theme::bold()
};
let hostname_style = theme::muted();
let alias_w = host.alias.width();
let leading = 2;
let checkbox_w = 3;
let gap = 2;
let vault_tag = if is_vault {
crate::messages::KEY_PUSH_VAULT_TAG
} else {
""
};
let vault_w = vault_tag.width();
let used = leading + checkbox_w + gap + alias_w + gap + vault_w;
let hostname_budget = content_w.saturating_sub(used);
let hostname_truncated = super::truncate(&host.hostname, hostname_budget);
vec![
Span::raw(" "),
Span::styled(checkbox.to_string(), checkbox_style),
Span::raw(" "),
Span::styled(host.alias.clone(), alias_style),
Span::raw(design::COL_GAP_STR),
Span::styled(hostname_truncated, hostname_style),
Span::styled(vault_tag.to_string(), theme::muted()),
]
}
fn build_row(
host: &HostEntry,
selected: &std::collections::HashSet<String>,
content_w: usize,
) -> ListItem<'static> {
ListItem::new(Line::from(build_row_spans(host, selected, content_w)))
}
#[cfg(test)]
mod tests {
use super::*;
fn h(alias: &str, hostname: &str, vault: Option<&str>) -> HostEntry {
HostEntry {
alias: alias.to_string(),
hostname: hostname.to_string(),
vault_ssh: vault.map(|s| s.to_string()),
..Default::default()
}
}
fn flatten(spans: &[Span<'_>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn build_row_marks_vault_with_dash() {
let host = h("prod-vault", "10.0.0.1", Some("ops/prod"));
let selected = std::collections::HashSet::new();
let text = flatten(&build_row_spans(&host, &selected, 80));
assert!(text.contains("[-]"));
assert!(text.contains("(vault)"));
}
#[test]
fn build_row_marks_selected_with_x() {
let host = h("prod", "10.0.0.2", None);
let mut selected = std::collections::HashSet::new();
selected.insert("prod".to_string());
let text = flatten(&build_row_spans(&host, &selected, 80));
assert!(text.contains("[x]"));
}
#[test]
fn build_row_unselected_shows_empty_box() {
let host = h("staging", "1.2.3.4", None);
let selected = std::collections::HashSet::new();
let text = flatten(&build_row_spans(&host, &selected, 80));
assert!(text.contains("[ ]"));
assert!(!text.contains("[x]"));
}
}