use std::collections::{HashMap, HashSet};
use ratatui::text::Span;
use crate::app::ping::PingStatus;
use crate::ssh_config::model::{ConfigElement, HostEntry, PatternEntry, SshConfigFile};
use crate::ui::theme;
pub struct HostState {
pub(in crate::app) ssh_config: SshConfigFile,
pub(in crate::app) list: Vec<HostEntry>,
pub(in crate::app) patterns: Vec<PatternEntry>,
pub(in crate::app) display_list: Vec<HostListItem>,
pub(in crate::app) render_cache: HostListRenderCache,
pub(in crate::app) undo_stack: Vec<DeletedHost>,
pub(in crate::app) multi_select: HashSet<usize>,
pub(in crate::app) sort_mode: SortMode,
pub(in crate::app) group_by: GroupBy,
pub(in crate::app) view_mode: ViewMode,
pub(in crate::app) group_filter: Option<String>,
pub(in crate::app) group_tab_order: Vec<String>,
pub(in crate::app) group_host_counts: HashMap<String, usize>,
}
impl HostState {
pub fn from_config(
ssh_config: SshConfigFile,
hosts: Vec<HostEntry>,
patterns: Vec<PatternEntry>,
display_list: Vec<HostListItem>,
) -> Self {
Self {
ssh_config,
list: hosts,
patterns,
display_list,
render_cache: HostListRenderCache::default(),
undo_stack: Vec::new(),
multi_select: HashSet::new(),
sort_mode: SortMode::Original,
group_by: GroupBy::None,
view_mode: ViewMode::Compact,
group_filter: None,
group_tab_order: Vec::new(),
group_host_counts: HashMap::new(),
}
}
pub fn set_group_by(&mut self, by: GroupBy) {
self.group_by = by;
self.group_filter = None;
}
pub fn toggle_view_mode(&mut self) {
self.view_mode = match self.view_mode {
ViewMode::Compact => ViewMode::Detailed,
ViewMode::Detailed => ViewMode::Compact,
};
}
pub fn toggle_multi_select(&mut self, idx: usize) -> bool {
let inserted = !self.multi_select.contains(&idx);
if inserted {
self.multi_select.insert(idx);
} else {
self.multi_select.remove(&idx);
}
inserted
}
pub fn ssh_config(&self) -> &SshConfigFile {
&self.ssh_config
}
pub fn ssh_config_mut(&mut self) -> &mut SshConfigFile {
&mut self.ssh_config
}
pub fn set_ssh_config(&mut self, config: SshConfigFile) {
self.ssh_config = config;
}
pub fn list(&self) -> &Vec<HostEntry> {
&self.list
}
pub fn list_mut(&mut self) -> &mut Vec<HostEntry> {
&mut self.list
}
pub fn patterns(&self) -> &Vec<PatternEntry> {
&self.patterns
}
pub fn patterns_mut(&mut self) -> &mut Vec<PatternEntry> {
&mut self.patterns
}
pub fn display_list(&self) -> &Vec<HostListItem> {
&self.display_list
}
pub fn display_list_mut(&mut self) -> &mut Vec<HostListItem> {
&mut self.display_list
}
pub fn render_cache(&self) -> &HostListRenderCache {
&self.render_cache
}
pub fn render_cache_mut(&mut self) -> &mut HostListRenderCache {
&mut self.render_cache
}
pub fn invalidate_render_cache(&mut self) {
self.render_cache.invalidate();
}
pub fn undo_stack(&self) -> &Vec<DeletedHost> {
&self.undo_stack
}
pub fn undo_stack_mut(&mut self) -> &mut Vec<DeletedHost> {
&mut self.undo_stack
}
pub fn pop_undo(&mut self) -> Option<DeletedHost> {
self.undo_stack.pop()
}
pub fn clear_undo(&mut self) {
self.undo_stack.clear();
}
pub fn multi_select(&self) -> &HashSet<usize> {
&self.multi_select
}
pub fn multi_select_mut(&mut self) -> &mut HashSet<usize> {
&mut self.multi_select
}
pub fn clear_multi_select(&mut self) {
self.multi_select.clear();
}
pub fn sort_mode(&self) -> SortMode {
self.sort_mode
}
pub fn set_sort_mode(&mut self, mode: SortMode) {
self.sort_mode = mode;
}
pub fn advance_sort_mode(&mut self) {
self.sort_mode = self.sort_mode.next();
}
pub fn group_by(&self) -> &GroupBy {
&self.group_by
}
pub fn set_group_by_raw(&mut self, by: GroupBy) {
self.group_by = by;
}
pub fn view_mode(&self) -> ViewMode {
self.view_mode
}
pub fn set_view_mode(&mut self, mode: ViewMode) {
self.view_mode = mode;
}
pub fn group_filter(&self) -> Option<&String> {
self.group_filter.as_ref()
}
pub fn set_group_filter(&mut self, filter: Option<String>) {
self.group_filter = filter;
}
pub fn group_tab_order(&self) -> &Vec<String> {
&self.group_tab_order
}
pub fn group_host_counts(&self) -> &HashMap<String, usize> {
&self.group_host_counts
}
}
#[cfg(test)]
impl Default for HostState {
fn default() -> Self {
Self {
ssh_config: SshConfigFile {
elements: Vec::new(),
path: std::path::PathBuf::new(),
crlf: false,
bom: false,
},
list: Vec::new(),
patterns: Vec::new(),
display_list: Vec::new(),
render_cache: HostListRenderCache::default(),
undo_stack: Vec::new(),
multi_select: HashSet::new(),
sort_mode: SortMode::Original,
group_by: GroupBy::None,
view_mode: ViewMode::Compact,
group_filter: None,
group_tab_order: Vec::new(),
group_host_counts: HashMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub enum HostListItem {
GroupHeader(String),
Host { index: usize },
Pattern { index: usize },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ViewMode {
Compact,
Detailed,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SortMode {
Original,
AlphaAlias,
AlphaHostname,
Frecency,
MostRecent,
Status,
}
impl SortMode {
pub fn next(self) -> Self {
match self {
SortMode::Original => SortMode::AlphaAlias,
SortMode::AlphaAlias => SortMode::AlphaHostname,
SortMode::AlphaHostname => SortMode::Frecency,
SortMode::Frecency => SortMode::MostRecent,
SortMode::MostRecent => SortMode::Status,
SortMode::Status => SortMode::Original,
}
}
pub fn label(self) -> &'static str {
match self {
SortMode::Original => "config order",
SortMode::AlphaAlias => "A-Z alias",
SortMode::AlphaHostname => "A-Z hostname",
SortMode::Frecency => "most used",
SortMode::MostRecent => "most recent",
SortMode::Status => "down first",
}
}
pub fn to_key(self) -> &'static str {
match self {
SortMode::Original => "original",
SortMode::AlphaAlias => "alpha_alias",
SortMode::AlphaHostname => "alpha_hostname",
SortMode::Frecency => "frecency",
SortMode::MostRecent => "most_recent",
SortMode::Status => "status",
}
}
pub fn from_key(s: &str) -> Self {
match s {
"original" => SortMode::Original,
"alpha_alias" => SortMode::AlphaAlias,
"alpha_hostname" => SortMode::AlphaHostname,
"frecency" => SortMode::Frecency,
"most_recent" => SortMode::MostRecent,
"status" => SortMode::Status,
_ => SortMode::MostRecent,
}
}
}
pub fn health_summary_spans(
ping_status: &HashMap<String, PingStatus>,
hosts: &[HostEntry],
) -> Vec<Span<'static>> {
health_summary_spans_for(ping_status, hosts.iter().map(|h| h.alias.as_str()))
}
pub fn health_summary_spans_for<'a>(
ping_status: &HashMap<String, PingStatus>,
aliases: impl Iterator<Item = &'a str>,
) -> Vec<Span<'static>> {
if ping_status.is_empty() {
return vec![];
}
let mut online = 0u32;
let mut slow = 0u32;
let mut down = 0u32;
let mut unchecked = 0u32;
for alias in aliases {
match ping_status.get(alias) {
Some(PingStatus::Reachable { .. }) => online += 1,
Some(PingStatus::Slow { .. }) => slow += 1,
Some(PingStatus::Unreachable) => down += 1,
Some(PingStatus::Checking) | None => unchecked += 1,
Some(PingStatus::Skipped) => {} }
}
let mut spans = Vec::new();
if online > 0 {
spans.push(Span::styled(
format!("\u{25CF}{online}"),
theme::online_dot(),
));
}
if slow > 0 {
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(format!("\u{25B2}{slow}"), theme::warning()));
}
if down > 0 {
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(format!("\u{2716}{down}"), theme::error()));
}
if unchecked > 0 {
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(format!("\u{25CB}{unchecked}"), theme::muted()));
}
spans
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupBy {
None,
Provider,
Tag(String),
}
impl GroupBy {
pub fn to_key(&self) -> String {
match self {
GroupBy::None => "none".to_string(),
GroupBy::Provider => "provider".to_string(),
GroupBy::Tag(tag) => format!("tag:{}", tag),
}
}
pub fn from_key(s: &str) -> Self {
match s {
"none" => GroupBy::None,
"provider" => GroupBy::Provider,
s if s.starts_with("tag:") => match s.strip_prefix("tag:") {
Some(tag) => GroupBy::Tag(tag.to_string()),
_ => GroupBy::None,
},
_ => GroupBy::None,
}
}
pub fn label(&self) -> String {
match self {
GroupBy::None => "ungrouped".to_string(),
GroupBy::Provider => "provider".to_string(),
GroupBy::Tag(tag) => format!("tag: {}", tag),
}
}
}
#[derive(Debug, Clone)]
pub struct DeletedHost {
pub element: ConfigElement,
pub position: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProxyJumpCandidate {
Host {
alias: String,
hostname: String,
suggested: bool,
},
SectionLabel(&'static str),
Separator,
}
#[derive(Default)]
pub struct HostListRenderCache {
pub history_width: Option<usize>,
pub group_alias_map: Option<HashMap<String, Vec<String>>>,
}
impl HostListRenderCache {
pub fn invalidate(&mut self) {
self.history_width = None;
self.group_alias_map = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_empty() {
let s = HostState::default();
assert!(s.list.is_empty());
assert!(s.patterns.is_empty());
assert!(s.display_list.is_empty());
assert!(s.undo_stack.is_empty());
assert!(s.multi_select.is_empty());
assert!(s.group_filter.is_none());
assert!(s.group_tab_order.is_empty());
assert!(s.group_host_counts.is_empty());
}
#[test]
fn set_group_by_provider_clears_filter() {
let mut s = HostState {
group_filter: Some("acme".to_string()),
..Default::default()
};
s.set_group_by(GroupBy::Provider);
assert!(matches!(s.group_by, GroupBy::Provider));
assert!(s.group_filter.is_none());
}
#[test]
fn set_group_by_none_clears_filter() {
let mut s = HostState {
group_by: GroupBy::Provider,
group_filter: Some("acme".to_string()),
..Default::default()
};
s.set_group_by(GroupBy::None);
assert!(matches!(s.group_by, GroupBy::None));
assert!(s.group_filter.is_none());
}
#[test]
fn set_group_by_tag_clears_filter() {
let mut s = HostState {
group_filter: Some("prod".to_string()),
..Default::default()
};
s.set_group_by(GroupBy::Tag("staging".to_string()));
match &s.group_by {
GroupBy::Tag(t) => assert_eq!(t, "staging"),
_ => panic!("expected Tag, got {:?}", s.group_by),
}
assert!(s.group_filter.is_none());
}
#[test]
fn set_group_by_overwrites_existing() {
let mut s = HostState {
group_by: GroupBy::Provider,
..Default::default()
};
s.set_group_by(GroupBy::None);
assert!(matches!(s.group_by, GroupBy::None));
}
#[test]
fn toggle_view_mode_compact_to_detailed() {
let mut s = HostState::default();
assert_eq!(s.view_mode, ViewMode::Compact);
s.toggle_view_mode();
assert_eq!(s.view_mode, ViewMode::Detailed);
}
#[test]
fn toggle_view_mode_detailed_to_compact() {
let mut s = HostState {
view_mode: ViewMode::Detailed,
..Default::default()
};
s.toggle_view_mode();
assert_eq!(s.view_mode, ViewMode::Compact);
}
#[test]
fn toggle_multi_select_inserts_when_absent_and_returns_true() {
let mut s = HostState::default();
let now_selected = s.toggle_multi_select(3);
assert!(now_selected);
assert!(s.multi_select.contains(&3));
}
#[test]
fn toggle_multi_select_removes_when_present_and_returns_false() {
let mut s = HostState::default();
s.multi_select.insert(3);
let now_selected = s.toggle_multi_select(3);
assert!(!now_selected);
assert!(!s.multi_select.contains(&3));
}
#[test]
fn toggle_multi_select_does_not_touch_other_indices() {
let mut s = HostState::default();
s.multi_select.insert(1);
s.multi_select.insert(2);
s.toggle_multi_select(3);
assert!(s.multi_select.contains(&1));
assert!(s.multi_select.contains(&2));
assert!(s.multi_select.contains(&3));
}
#[test]
fn advance_sort_mode_steps_to_next_variant() {
let mut s = HostState::default();
assert_eq!(s.sort_mode, SortMode::Original);
s.advance_sort_mode();
assert_eq!(s.sort_mode, SortMode::AlphaAlias);
}
#[test]
fn advance_sort_mode_wraps_from_last_to_first() {
let mut s = HostState {
sort_mode: SortMode::Status,
..Default::default()
};
s.advance_sort_mode();
assert_eq!(s.sort_mode, SortMode::Original);
}
#[test]
fn set_group_by_raw_keeps_active_filter() {
let mut s = HostState {
group_filter: Some("acme".to_string()),
..Default::default()
};
s.set_group_by_raw(GroupBy::Provider);
assert!(matches!(s.group_by, GroupBy::Provider));
assert_eq!(s.group_filter.as_deref(), Some("acme"));
}
#[test]
fn clear_multi_select_empties_the_set() {
let mut s = HostState::default();
s.multi_select.insert(1);
s.multi_select.insert(2);
s.clear_multi_select();
assert!(s.multi_select.is_empty());
}
#[test]
fn pop_undo_returns_most_recent_deletion() {
let mut s = HostState::default();
s.undo_stack.push(DeletedHost {
element: ConfigElement::GlobalLine(String::new()),
position: 0,
});
s.undo_stack.push(DeletedHost {
element: ConfigElement::GlobalLine(String::new()),
position: 7,
});
let popped = s.pop_undo().expect("undo entry present");
assert_eq!(popped.position, 7);
assert_eq!(s.undo_stack.len(), 1);
}
#[test]
fn pop_undo_returns_none_when_empty() {
let mut s = HostState::default();
assert!(s.pop_undo().is_none());
}
#[test]
fn clear_undo_empties_the_stack() {
let mut s = HostState::default();
s.undo_stack.push(DeletedHost {
element: ConfigElement::GlobalLine(String::new()),
position: 0,
});
s.clear_undo();
assert!(s.undo_stack.is_empty());
}
#[test]
fn invalidate_render_cache_clears_cached_fields() {
let mut s = HostState::default();
s.render_cache.history_width = Some(12);
s.render_cache.group_alias_map = Some(HashMap::new());
s.invalidate_render_cache();
assert!(s.render_cache.history_width.is_none());
assert!(s.render_cache.group_alias_map.is_none());
}
}