use std::io::Stdout;
use std::time::Duration;
use ratatui::{backend::CrosstermBackend, Terminal};
use super::*;
use crate::ssh::client::HostSource;
pub const FORM_FIELD_LABELS: &[&str] = &[
"Name",
"Hostname / IP",
"User",
"Port",
"Identity File",
"Password (optional)",
"Tags (comma-sep)",
"Notes",
];
#[derive(Debug, Clone, Default)]
pub struct FormField {
pub value: String,
pub cursor: usize,
}
impl FormField {
pub fn with_value(s: impl Into<String>) -> Self {
let value = s.into();
let cursor = value.len();
Self { value, cursor }
}
pub fn insert_char(&mut self, c: char) {
self.value.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
pub fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let prev = self.value[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.value.drain(prev..self.cursor);
self.cursor = prev;
}
}
#[derive(Debug, Clone)]
pub struct HostForm {
pub fields: Vec<FormField>,
pub focused_field: usize,
}
impl HostForm {
pub fn empty() -> Self {
Self {
fields: FORM_FIELD_LABELS
.iter()
.map(|_| FormField::default())
.collect(),
focused_field: 0,
}
}
pub fn from_host(host: &Host) -> Self {
let mut form = Self::empty();
form.fields[0] = FormField::with_value(&host.name);
form.fields[1] = FormField::with_value(&host.hostname);
form.fields[2] = FormField::with_value(&host.user);
form.fields[3] = FormField::with_value(host.port.to_string());
form.fields[4] = FormField::with_value(host.identity_file.as_deref().unwrap_or(""));
form.fields[5] = FormField::with_value(host.password.as_deref().unwrap_or(""));
form.fields[6] = FormField::with_value(host.tags.join(", "));
form.fields[7] = FormField::with_value(host.notes.as_deref().unwrap_or(""));
form
}
pub fn to_host(&self, source: HostSource) -> Result<Host, String> {
let name = self.fields[0].value.trim().to_string();
if name.is_empty() {
return Err("Name cannot be empty".to_string());
}
let hostname = self.fields[1].value.trim().to_string();
if hostname.is_empty() {
return Err("Hostname / IP cannot be empty".to_string());
}
let user = {
let v = self.fields[2].value.trim();
if v.is_empty() {
"root".to_string()
} else {
v.to_string()
}
};
let port = {
let v = self.fields[3].value.trim();
if v.is_empty() {
22u16
} else {
v.parse::<u16>().ok().filter(|&p| p != 0).ok_or_else(|| {
format!("Port must be a number between 1 and 65535, got '{v}'")
})?
}
};
let identity_file = {
let v = self.fields[4].value.trim();
if v.is_empty() {
None
} else {
Some(v.to_string())
}
};
let password = {
let v = self.fields[5].value.trim();
if v.is_empty() {
None
} else {
Some(v.to_string())
}
};
let tags: Vec<String> = self.fields[6]
.value
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
let notes = {
let v = self.fields[7].value.trim();
if v.is_empty() {
None
} else {
Some(v.to_string())
}
};
Ok(Host {
name,
hostname,
user,
port,
identity_file,
password,
proxy_jump: None,
tags,
notes,
source,
original_ssh_host: None,
key_setup_date: None,
password_auth_disabled: None,
})
}
pub fn focus_next(&mut self) {
self.focused_field = (self.focused_field + 1) % self.fields.len();
}
pub fn focus_prev(&mut self) {
if self.focused_field == 0 {
self.focused_field = self.fields.len() - 1;
} else {
self.focused_field -= 1;
}
}
}
#[derive(Debug, Clone)]
pub enum HostPopup {
Add(HostForm),
Edit { host_idx: usize, form: HostForm },
DeleteConfirm(usize),
KeySetupConfirm(usize),
KeySetupProgress {
host_idx: usize,
host_name: String,
current_step: Option<crate::ssh::key_setup::KeySetupStep>,
},
}
#[derive(Debug, Default)]
pub struct HostListView {
pub selected: usize,
pub search_mode: bool,
pub search_query: String,
pub filtered_indices: Vec<usize>,
pub popup: Option<HostPopup>,
pub pending_connect: Option<Host>,
pub sort_order: SortOrder,
pub tag_filter: Option<String>,
pub tag_popup_open: bool,
pub tag_popup_selected: usize,
pub available_tags: Vec<String>,
}
impl HostListView {
pub fn selected_host_idx(&self) -> Option<usize> {
self.filtered_indices.get(self.selected).copied()
}
pub fn rebuild_filter(
&mut self,
hosts: &[Host],
metrics: &HashMap<String, Metrics>,
statuses: &HashMap<String, ConnectionStatus>,
) {
use std::cmp::Ordering;
let mut indices = filter_hosts(hosts, &self.search_query);
indices.retain(|&i| i < hosts.len());
if let Some(tag) = &self.tag_filter {
let tag = tag.clone();
indices.retain(|&i| hosts[i].tags.contains(&tag));
}
match self.sort_order {
SortOrder::Name => {
indices.sort_by(|&a, &b| hosts[a].name.cmp(&hosts[b].name));
}
SortOrder::Cpu => {
indices.sort_by(|&a, &b| {
let ca = metrics
.get(&hosts[a].name)
.and_then(|m| m.cpu_percent)
.unwrap_or(-1.0);
let cb = metrics
.get(&hosts[b].name)
.and_then(|m| m.cpu_percent)
.unwrap_or(-1.0);
cb.partial_cmp(&ca).unwrap_or(Ordering::Equal)
});
}
SortOrder::Ram => {
indices.sort_by(|&a, &b| {
let ra = metrics
.get(&hosts[a].name)
.and_then(|m| m.ram_percent)
.unwrap_or(-1.0);
let rb = metrics
.get(&hosts[b].name)
.and_then(|m| m.ram_percent)
.unwrap_or(-1.0);
rb.partial_cmp(&ra).unwrap_or(Ordering::Equal)
});
}
SortOrder::Status => {
indices.sort_by(|&a, &b| {
let sa = status_priority(statuses.get(&hosts[a].name));
let sb = status_priority(statuses.get(&hosts[b].name));
sa.cmp(&sb)
});
}
}
self.filtered_indices = indices;
if self.filtered_indices.is_empty() {
self.selected = 0;
} else if self.selected >= self.filtered_indices.len() {
self.selected = self.filtered_indices.len() - 1;
}
}
pub fn select_next(&mut self) {
if self.filtered_indices.is_empty() {
return;
}
self.selected = (self.selected + 1).min(self.filtered_indices.len() - 1);
}
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn rebuild_tags(&mut self, hosts: &[Host]) {
let mut tags: Vec<String> = hosts.iter().flat_map(|h| h.tags.iter().cloned()).collect();
tags.sort();
tags.dedup();
self.available_tags = tags;
}
}
fn status_priority(status: Option<&ConnectionStatus>) -> u8 {
match status {
Some(ConnectionStatus::Connected) => 0,
Some(ConnectionStatus::Connecting) => 1,
Some(ConnectionStatus::Unknown) | None => 2,
Some(ConnectionStatus::Failed(_)) => 3,
}
}
pub fn filter_hosts(hosts: &[Host], query: &str) -> Vec<usize> {
if query.is_empty() {
return (0..hosts.len()).collect();
}
let q = query.to_lowercase();
hosts
.iter()
.enumerate()
.filter(|(_, h)| {
h.name.to_lowercase().contains(&q)
|| h.hostname.to_lowercase().contains(&q)
|| h.tags.iter().any(|t| t.to_lowercase().contains(&q))
|| h.notes
.as_deref()
.map(|n| n.to_lowercase().contains(&q))
.unwrap_or(false)
})
.map(|(i, _)| i)
.collect()
}
impl App {
pub(crate) async fn handle_confirm_form(&mut self) {
match self.view.host_list.popup.take() {
Some(HostPopup::Add(form)) => {
match form.to_host(HostSource::Manual) {
Ok(host) => {
let has_password = host.password.is_some();
let host_name = host.name.clone();
{
let mut state = self.state.write().await;
state.hosts.push(host);
}
self.save_manual_hosts().await;
{
let state = self.state.read().await;
if let Some(old) = self.poll_manager.take() {
old.shutdown();
}
self.poll_manager = Some(PollManager::start(
state.hosts.clone(),
self.event_tx.clone(),
Duration::from_secs(30),
));
}
let state = self.state.read().await;
self.view.host_list.rebuild_filter(
&state.hosts,
&state.metrics,
&state.connection_statuses,
);
self.view.host_list.rebuild_tags(&state.hosts);
if has_password {
self.view.status_message = Some(format!(
"Host '{}' added. Press 'Shift+K' to set up SSH key authentication (recommended).",
host_name
));
} else {
self.view.status_message = Some("Host added.".to_string());
}
}
Err(e) => {
self.view.host_list.popup = Some(HostPopup::Add(form));
self.view.status_message = Some(format!("Error: {e}"));
}
}
}
Some(HostPopup::Edit { host_idx, form }) => match form.to_host(HostSource::Manual) {
Ok(mut host) => {
let (old_name, _was_ssh_config) = {
let mut state = self.state.write().await;
let old_host = state.hosts.get(host_idx);
let old_name = old_host.map(|h| h.name.clone());
let was_ssh_config = old_host
.map(|h| h.source == HostSource::SshConfig)
.unwrap_or(false);
if was_ssh_config && old_name.is_some() {
host.original_ssh_host = old_name.clone();
}
if let Some(slot) = state.hosts.get_mut(host_idx) {
*slot = host.clone();
}
(old_name, was_ssh_config)
};
if let Some(old_name) = old_name {
if old_name != host.name {
let mut state = self.state.write().await;
if let Some(metrics) = state.metrics.remove(&old_name) {
state.metrics.insert(host.name.clone(), metrics);
}
if let Some(status) = state.connection_statuses.remove(&old_name) {
state.connection_statuses.insert(host.name.clone(), status);
}
if let Some(services) = state.services.remove(&old_name) {
state.services.insert(host.name.clone(), services);
}
if let Some(alerts) = state.alerts.remove(&old_name) {
state.alerts.insert(host.name.clone(), alerts);
}
if let Some(discovery) = state.discovery_status.remove(&old_name) {
state.discovery_status.insert(host.name.clone(), discovery);
}
}
}
self.save_manual_hosts().await;
let state = self.state.read().await;
self.view.host_list.rebuild_filter(
&state.hosts,
&state.metrics,
&state.connection_statuses,
);
self.view.host_list.rebuild_tags(&state.hosts);
self.view.status_message = Some("Host updated.".to_string());
}
Err(e) => {
self.view.host_list.popup = Some(HostPopup::Edit { host_idx, form });
self.view.status_message = Some(format!("Error: {e}"));
}
},
other => {
self.view.host_list.popup = other;
}
}
}
pub(crate) async fn handle_confirm_delete(&mut self) {
if let Some(HostPopup::DeleteConfirm(idx)) = self.view.host_list.popup.take() {
{
let mut state = self.state.write().await;
if idx < state.hosts.len() {
let removed = state.hosts.remove(idx);
self.view.status_message = Some(format!("Deleted '{}'.", removed.name));
}
}
self.save_manual_hosts().await;
let state = self.state.read().await;
self.view.host_list.rebuild_filter(
&state.hosts,
&state.metrics,
&state.connection_statuses,
);
self.view.host_list.rebuild_tags(&state.hosts);
}
}
pub(crate) async fn save_manual_hosts(&mut self) {
let hosts = self.state.read().await.hosts.clone();
if let Err(e) = config::save_hosts(&hosts) {
self.view.status_message = Some(format!("Save failed: {e}"));
}
}
pub(crate) async fn connect_system_ssh(
&mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
host: &Host,
) -> anyhow::Result<()> {
leave_tui()?;
let mut cmd = tokio::process::Command::new("ssh");
cmd.args(["-o", "ConnectTimeout=10"]);
if host.port != 22 {
cmd.args(["-p", &host.port.to_string()]);
}
if let Some(ref key) = host.identity_file {
cmd.args(["-i", key]);
}
if let Some(ref jump) = host.proxy_jump {
cmd.args(["-J", jump]);
}
cmd.arg(format!("{}@{}", host.user, host.hostname));
tracing::info!("Connecting to {} via system SSH", host.name);
let outcome = match cmd.spawn() {
Ok(mut child) => child.wait().await,
Err(e) => Err(e),
};
enter_tui()?;
terminal.clear()?;
self.view.status_message = Some(match outcome {
Ok(status) if status.success() => format!("Disconnected from '{}'.", host.name),
Ok(status) => format!(
"SSH to '{}' exited with code {:?}.",
host.name,
status.code()
),
Err(e) => format!("Failed to launch ssh for '{}': {e}", host.name),
});
Ok(())
}
}
fn leave_tui() -> anyhow::Result<()> {
crossterm::terminal::disable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crate::utils::mouse::DisableMinimalMouseCapture,
crossterm::event::DisableBracketedPaste,
)?;
Ok(())
}
fn enter_tui() -> anyhow::Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crate::utils::mouse::EnableMinimalMouseCapture,
crossterm::event::EnableBracketedPaste,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn host_form(values: [&str; 8]) -> HostForm {
let mut form = HostForm::empty();
for (i, v) in values.iter().enumerate() {
form.fields[i] = FormField::with_value(*v);
}
form
}
#[test]
fn to_host_empty_name_errs() {
let form = host_form(["", "h", "u", "22", "", "", "", ""]);
assert!(form.to_host(HostSource::Manual).is_err());
}
#[test]
fn to_host_whitespace_name_errs() {
let form = host_form([" ", "h", "u", "22", "", "", "", ""]);
assert!(form.to_host(HostSource::Manual).is_err());
}
#[test]
fn to_host_empty_hostname_errs() {
let form = host_form(["n", "", "u", "22", "", "", "", ""]);
assert!(form.to_host(HostSource::Manual).is_err());
}
#[test]
fn to_host_empty_user_defaults_root() {
let host = host_form(["n", "h", "", "22", "", "", "", ""])
.to_host(HostSource::Manual)
.unwrap();
assert_eq!(host.user, "root");
}
#[test]
fn to_host_whitespace_user_defaults_root() {
let host = host_form(["n", "h", " ", "22", "", "", "", ""])
.to_host(HostSource::Manual)
.unwrap();
assert_eq!(host.user, "root");
}
#[test]
fn to_host_empty_port_defaults_22() {
let host = host_form(["n", "h", "u", "", "", "", "", ""])
.to_host(HostSource::Manual)
.unwrap();
assert_eq!(host.port, 22);
}
#[test]
fn to_host_valid_port_parsed() {
let host = host_form(["n", "h", "u", "2222", "", "", "", ""])
.to_host(HostSource::Manual)
.unwrap();
assert_eq!(host.port, 2222);
}
#[test]
fn to_host_port_max_accepted() {
let host = host_form(["n", "h", "u", "65535", "", "", "", ""])
.to_host(HostSource::Manual)
.unwrap();
assert_eq!(host.port, 65535);
}
#[test]
fn to_host_port_zero_errs() {
assert!(host_form(["n", "h", "u", "0", "", "", "", ""])
.to_host(HostSource::Manual)
.is_err());
}
#[test]
fn to_host_port_overflow_errs() {
let err = host_form(["n", "h", "u", "65536", "", "", "", ""])
.to_host(HostSource::Manual)
.unwrap_err();
assert!(err.contains("between 1 and 65535"));
}
#[test]
fn to_host_port_non_numeric_errs() {
assert!(host_form(["n", "h", "u", "abc", "", "", "", ""])
.to_host(HostSource::Manual)
.is_err());
}
#[test]
fn to_host_port_negative_errs() {
assert!(host_form(["n", "h", "u", "-1", "", "", "", ""])
.to_host(HostSource::Manual)
.is_err());
}
#[test]
fn to_host_optional_fields_none_when_empty() {
let host = host_form(["n", "h", "u", "22", "", "", "", ""])
.to_host(HostSource::Manual)
.unwrap();
assert!(host.identity_file.is_none());
assert!(host.password.is_none());
assert!(host.notes.is_none());
assert!(host.tags.is_empty());
}
#[test]
fn to_host_tags_split_and_trimmed() {
let host = host_form(["n", "h", "u", "22", "", "", "a, b ,c,,", ""])
.to_host(HostSource::Manual)
.unwrap();
assert_eq!(host.tags, vec!["a", "b", "c"]);
}
#[test]
fn to_host_source_and_metadata_propagated() {
let host = host_form(["n", "h", "u", "22", "", "", "", ""])
.to_host(HostSource::SshConfig)
.unwrap();
assert_eq!(host.source, HostSource::SshConfig);
assert!(host.original_ssh_host.is_none());
assert!(host.key_setup_date.is_none());
assert!(host.password_auth_disabled.is_none());
}
#[test]
fn formfield_insert_ascii() {
let mut f = FormField::default();
f.insert_char('a');
assert_eq!(f.value, "a");
assert_eq!(f.cursor, 1);
}
#[test]
fn formfield_insert_multibyte_advances_cursor_by_byte_len() {
let mut f = FormField::default();
f.insert_char('é');
assert_eq!(f.value, "é");
assert_eq!(f.cursor, 2);
}
#[test]
fn formfield_insert_emoji_advances_cursor_by_four() {
let mut f = FormField::default();
f.insert_char('😀');
assert_eq!(f.cursor, 4);
}
#[test]
fn formfield_insert_at_start() {
let mut f = FormField::with_value("bc");
f.cursor = 0;
f.insert_char('a');
assert_eq!(f.value, "abc");
assert_eq!(f.cursor, 1);
}
#[test]
fn formfield_insert_on_multibyte_boundary_stays_valid() {
let mut f = FormField::with_value("aé");
f.cursor = 1;
f.insert_char('X');
assert_eq!(f.value, "aXé");
}
#[test]
fn formfield_backspace_ascii() {
let mut f = FormField::with_value("ab");
f.backspace();
assert_eq!(f.value, "a");
assert_eq!(f.cursor, 1);
}
#[test]
fn formfield_backspace_removes_whole_multibyte_char() {
let mut f = FormField::with_value("é");
f.backspace();
assert_eq!(f.value, "");
assert_eq!(f.cursor, 0);
}
#[test]
fn formfield_backspace_at_zero_is_noop() {
let mut f = FormField::default();
f.backspace();
assert_eq!(f.value, "");
assert_eq!(f.cursor, 0);
}
#[test]
fn formfield_with_value_cursor_at_byte_len() {
let f = FormField::with_value("héllo");
assert_eq!(f.cursor, 6);
}
fn host(name: &str, hostname: &str, tags: &[&str], notes: Option<&str>) -> Host {
Host {
name: name.to_string(),
hostname: hostname.to_string(),
tags: tags.iter().map(|t| t.to_string()).collect(),
notes: notes.map(|n| n.to_string()),
..Host::default()
}
}
#[test]
fn filter_hosts_empty_query_returns_all() {
let hosts = [host("a", "1", &[], None), host("b", "2", &[], None)];
assert_eq!(filter_hosts(&hosts, ""), vec![0, 1]);
}
#[test]
fn filter_hosts_matches_name() {
let hosts = [host("web-prod", "1", &[], None), host("db", "2", &[], None)];
assert_eq!(filter_hosts(&hosts, "web"), vec![0]);
}
#[test]
fn filter_hosts_matches_hostname() {
let hosts = [
host("a", "10.0.0.1", &[], None),
host("b", "10.0.0.2", &[], None),
];
assert_eq!(filter_hosts(&hosts, "0.0.1"), vec![0]);
}
#[test]
fn filter_hosts_matches_tag() {
let hosts = [
host("a", "1", &["prod"], None),
host("b", "2", &["dev"], None),
];
assert_eq!(filter_hosts(&hosts, "prod"), vec![0]);
}
#[test]
fn filter_hosts_matches_notes() {
let hosts = [
host("a", "1", &[], Some("bastion host")),
host("b", "2", &[], None),
];
assert_eq!(filter_hosts(&hosts, "bastion"), vec![0]);
}
#[test]
fn filter_hosts_case_insensitive() {
let hosts = [host("Web-Prod", "1", &[], None)];
assert_eq!(filter_hosts(&hosts, "WEB"), vec![0]);
}
#[test]
fn filter_hosts_no_match_returns_empty() {
let hosts = [host("a", "1", &[], None)];
assert!(filter_hosts(&hosts, "zzz").is_empty());
}
fn filtered_names<'a>(view: &HostListView, hosts: &'a [Host]) -> Vec<&'a str> {
view.filtered_indices
.iter()
.map(|&i| hosts[i].name.as_str())
.collect()
}
fn metrics_cpu(value: f64) -> Metrics {
Metrics {
cpu_percent: Some(value),
..Metrics::default()
}
}
fn metrics_ram(value: f64) -> Metrics {
Metrics {
ram_percent: Some(value),
..Metrics::default()
}
}
#[test]
fn rebuild_sort_name_alphabetical() {
let hosts = [
host("c", "1", &[], None),
host("a", "2", &[], None),
host("b", "3", &[], None),
];
let mut view = HostListView::default();
view.rebuild_filter(&hosts, &HashMap::new(), &HashMap::new());
assert_eq!(filtered_names(&view, &hosts), ["a", "b", "c"]);
}
#[test]
fn rebuild_sort_cpu_descending_missing_metrics_last() {
let hosts = [
host("a", "1", &[], None),
host("b", "2", &[], None),
host("c", "3", &[], None),
];
let metrics = HashMap::from([
("a".to_string(), metrics_cpu(90.0)),
("b".to_string(), metrics_cpu(10.0)),
]);
let mut view = HostListView {
sort_order: SortOrder::Cpu,
..Default::default()
};
view.rebuild_filter(&hosts, &metrics, &HashMap::new());
assert_eq!(filtered_names(&view, &hosts), ["a", "b", "c"]);
}
#[test]
fn rebuild_sort_ram_descending() {
let hosts = [host("a", "1", &[], None), host("b", "2", &[], None)];
let metrics = HashMap::from([
("a".to_string(), metrics_ram(20.0)),
("b".to_string(), metrics_ram(80.0)),
]);
let mut view = HostListView {
sort_order: SortOrder::Ram,
..Default::default()
};
view.rebuild_filter(&hosts, &metrics, &HashMap::new());
assert_eq!(filtered_names(&view, &hosts), ["b", "a"]);
}
#[test]
fn rebuild_sort_status_priority() {
let hosts = [
host("a", "1", &[], None),
host("b", "2", &[], None),
host("c", "3", &[], None),
host("d", "4", &[], None),
];
let statuses = HashMap::from([
("a".to_string(), ConnectionStatus::Failed("x".to_string())),
("b".to_string(), ConnectionStatus::Connected),
("c".to_string(), ConnectionStatus::Connecting),
]);
let mut view = HostListView {
sort_order: SortOrder::Status,
..Default::default()
};
view.rebuild_filter(&hosts, &HashMap::new(), &statuses);
assert_eq!(filtered_names(&view, &hosts), ["b", "c", "d", "a"]);
}
#[test]
fn rebuild_tag_filter_includes_only_matching() {
let hosts = [
host("a", "1", &["prod"], None),
host("b", "2", &["dev"], None),
];
let mut view = HostListView {
tag_filter: Some("prod".to_string()),
..Default::default()
};
view.rebuild_filter(&hosts, &HashMap::new(), &HashMap::new());
assert_eq!(filtered_names(&view, &hosts), ["a"]);
}
#[test]
fn rebuild_tag_filter_none_shows_all() {
let hosts = [
host("a", "1", &["prod"], None),
host("b", "2", &["dev"], None),
];
let mut view = HostListView::default();
view.rebuild_filter(&hosts, &HashMap::new(), &HashMap::new());
assert_eq!(view.filtered_indices.len(), 2);
}
#[test]
fn rebuild_combines_text_tag_and_sort() {
let hosts = [
host("web1", "1", &["prod"], None),
host("web2", "2", &["dev"], None),
host("db1", "3", &["prod"], None),
];
let mut view = HostListView {
search_query: "web".to_string(),
tag_filter: Some("prod".to_string()),
..Default::default()
};
view.rebuild_filter(&hosts, &HashMap::new(), &HashMap::new());
assert_eq!(filtered_names(&view, &hosts), ["web1"]);
}
#[test]
fn rebuild_clamps_selection_when_list_shrinks() {
let hosts = [host("a", "1", &[], None), host("b", "2", &[], None)];
let mut view = HostListView {
selected: 5,
..Default::default()
};
view.rebuild_filter(&hosts, &HashMap::new(), &HashMap::new());
assert_eq!(view.selected, 1);
}
#[test]
fn rebuild_resets_selection_to_zero_when_empty() {
let hosts = [host("a", "1", &[], None)];
let mut view = HostListView {
selected: 3,
search_query: "zzz".to_string(),
..Default::default()
};
view.rebuild_filter(&hosts, &HashMap::new(), &HashMap::new());
assert_eq!(view.selected, 0);
}
#[test]
fn rebuild_preserves_selection_in_range() {
let hosts = [
host("a", "1", &[], None),
host("b", "2", &[], None),
host("c", "3", &[], None),
];
let mut view = HostListView {
selected: 1,
..Default::default()
};
view.rebuild_filter(&hosts, &HashMap::new(), &HashMap::new());
assert_eq!(view.selected, 1);
}
#[test]
fn rebuild_handles_shrunk_host_list_without_panic() {
let many = [
host("a", "1", &[], None),
host("b", "2", &[], None),
host("c", "3", &[], None),
];
let mut view = HostListView::default();
view.rebuild_filter(&many, &HashMap::new(), &HashMap::new());
let few = [host("a", "1", &[], None)];
view.rebuild_filter(&few, &HashMap::new(), &HashMap::new());
assert!(view.filtered_indices.iter().all(|&i| i < few.len()));
}
#[test]
fn rebuild_cpu_sort_with_empty_metrics_does_not_panic() {
let hosts = [host("a", "1", &[], None), host("b", "2", &[], None)];
let mut view = HostListView {
sort_order: SortOrder::Cpu,
..Default::default()
};
view.rebuild_filter(&hosts, &HashMap::new(), &HashMap::new());
assert_eq!(view.filtered_indices.len(), 2);
}
#[test]
fn host_select_next_clamps_at_last() {
let mut view = HostListView {
filtered_indices: vec![0, 1],
..Default::default()
};
view.select_next();
view.select_next();
view.select_next();
assert_eq!(view.selected, 1);
}
#[test]
fn host_select_prev_saturates_at_zero() {
let mut view = HostListView {
filtered_indices: vec![0, 1],
..Default::default()
};
view.select_prev();
assert_eq!(view.selected, 0);
}
#[test]
fn host_select_next_noop_when_empty() {
let mut view = HostListView::default();
view.select_next();
assert_eq!(view.selected, 0);
}
}