1use std::collections::{HashMap, HashSet};
2
3use ratatui::text::Span;
4
5use crate::app::ping::PingStatus;
6use crate::ssh_config::model::{ConfigElement, HostEntry, PatternEntry, SshConfigFile};
7use crate::ui::theme;
8
9pub struct HostState {
15 pub ssh_config: SshConfigFile,
16 pub list: Vec<HostEntry>,
17 pub patterns: Vec<PatternEntry>,
18 pub display_list: Vec<HostListItem>,
19 pub render_cache: HostListRenderCache,
20 pub undo_stack: Vec<DeletedHost>,
21 pub multi_select: HashSet<usize>,
23 pub sort_mode: SortMode,
24 pub group_by: GroupBy,
25 pub view_mode: ViewMode,
26 pub group_filter: Option<String>,
28 pub group_tab_order: Vec<String>,
30 pub group_host_counts: HashMap<String, usize>,
32}
33
34impl HostState {
35 pub fn from_config(
37 ssh_config: SshConfigFile,
38 hosts: Vec<HostEntry>,
39 patterns: Vec<PatternEntry>,
40 display_list: Vec<HostListItem>,
41 ) -> Self {
42 Self {
43 ssh_config,
44 list: hosts,
45 patterns,
46 display_list,
47 render_cache: HostListRenderCache::default(),
48 undo_stack: Vec::new(),
49 multi_select: HashSet::new(),
50 sort_mode: SortMode::Original,
51 group_by: GroupBy::None,
52 view_mode: ViewMode::Compact,
53 group_filter: None,
54 group_tab_order: Vec::new(),
55 group_host_counts: HashMap::new(),
56 }
57 }
58
59 pub fn set_group_by(&mut self, by: GroupBy) {
63 self.group_by = by;
64 self.group_filter = None;
65 }
66
67 pub fn toggle_view_mode(&mut self) {
69 self.view_mode = match self.view_mode {
70 ViewMode::Compact => ViewMode::Detailed,
71 ViewMode::Detailed => ViewMode::Compact,
72 };
73 }
74
75 pub fn toggle_multi_select(&mut self, idx: usize) -> bool {
80 let inserted = !self.multi_select.contains(&idx);
81 if inserted {
82 self.multi_select.insert(idx);
83 } else {
84 self.multi_select.remove(&idx);
85 }
86 inserted
87 }
88}
89
90#[cfg(test)]
91impl Default for HostState {
92 fn default() -> Self {
93 Self {
94 ssh_config: SshConfigFile {
95 elements: Vec::new(),
96 path: std::path::PathBuf::new(),
97 crlf: false,
98 bom: false,
99 },
100 list: Vec::new(),
101 patterns: Vec::new(),
102 display_list: Vec::new(),
103 render_cache: HostListRenderCache::default(),
104 undo_stack: Vec::new(),
105 multi_select: HashSet::new(),
106 sort_mode: SortMode::Original,
107 group_by: GroupBy::None,
108 view_mode: ViewMode::Compact,
109 group_filter: None,
110 group_tab_order: Vec::new(),
111 group_host_counts: HashMap::new(),
112 }
113 }
114}
115
116#[derive(Debug, Clone)]
118pub enum HostListItem {
119 GroupHeader(String),
120 Host { index: usize },
121 Pattern { index: usize },
122}
123
124#[derive(Debug, Clone, Copy, PartialEq)]
126pub enum ViewMode {
127 Compact,
128 Detailed,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq)]
133pub enum SortMode {
134 Original,
135 AlphaAlias,
136 AlphaHostname,
137 Frecency,
138 MostRecent,
139 Status,
140}
141
142impl SortMode {
143 pub fn next(self) -> Self {
144 match self {
145 SortMode::Original => SortMode::AlphaAlias,
146 SortMode::AlphaAlias => SortMode::AlphaHostname,
147 SortMode::AlphaHostname => SortMode::Frecency,
148 SortMode::Frecency => SortMode::MostRecent,
149 SortMode::MostRecent => SortMode::Status,
150 SortMode::Status => SortMode::Original,
151 }
152 }
153
154 pub fn label(self) -> &'static str {
155 match self {
156 SortMode::Original => "config order",
157 SortMode::AlphaAlias => "A-Z alias",
158 SortMode::AlphaHostname => "A-Z hostname",
159 SortMode::Frecency => "most used",
160 SortMode::MostRecent => "most recent",
161 SortMode::Status => "down first",
162 }
163 }
164
165 pub fn to_key(self) -> &'static str {
166 match self {
167 SortMode::Original => "original",
168 SortMode::AlphaAlias => "alpha_alias",
169 SortMode::AlphaHostname => "alpha_hostname",
170 SortMode::Frecency => "frecency",
171 SortMode::MostRecent => "most_recent",
172 SortMode::Status => "status",
173 }
174 }
175
176 pub fn from_key(s: &str) -> Self {
177 match s {
178 "original" => SortMode::Original,
179 "alpha_alias" => SortMode::AlphaAlias,
180 "alpha_hostname" => SortMode::AlphaHostname,
181 "frecency" => SortMode::Frecency,
182 "most_recent" => SortMode::MostRecent,
183 "status" => SortMode::Status,
184 _ => SortMode::MostRecent,
185 }
186 }
187}
188
189pub fn health_summary_spans(
192 ping_status: &HashMap<String, PingStatus>,
193 hosts: &[HostEntry],
194) -> Vec<Span<'static>> {
195 health_summary_spans_for(ping_status, hosts.iter().map(|h| h.alias.as_str()))
196}
197
198pub fn health_summary_spans_for<'a>(
201 ping_status: &HashMap<String, PingStatus>,
202 aliases: impl Iterator<Item = &'a str>,
203) -> Vec<Span<'static>> {
204 if ping_status.is_empty() {
205 return vec![];
206 }
207 let mut online = 0u32;
208 let mut slow = 0u32;
209 let mut down = 0u32;
210 let mut unchecked = 0u32;
211 for alias in aliases {
212 match ping_status.get(alias) {
213 Some(PingStatus::Reachable { .. }) => online += 1,
214 Some(PingStatus::Slow { .. }) => slow += 1,
215 Some(PingStatus::Unreachable) => down += 1,
216 Some(PingStatus::Checking) | None => unchecked += 1,
217 Some(PingStatus::Skipped) => {} }
219 }
220 let mut spans = Vec::new();
221 if online > 0 {
222 spans.push(Span::styled(
223 format!("\u{25CF}{online}"),
224 theme::online_dot(),
225 ));
226 }
227 if slow > 0 {
228 if !spans.is_empty() {
229 spans.push(Span::raw(" "));
230 }
231 spans.push(Span::styled(format!("\u{25B2}{slow}"), theme::warning()));
232 }
233 if down > 0 {
234 if !spans.is_empty() {
235 spans.push(Span::raw(" "));
236 }
237 spans.push(Span::styled(format!("\u{2716}{down}"), theme::error()));
238 }
239 if unchecked > 0 {
240 if !spans.is_empty() {
241 spans.push(Span::raw(" "));
242 }
243 spans.push(Span::styled(format!("\u{25CB}{unchecked}"), theme::muted()));
244 }
245 spans
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum GroupBy {
251 None,
252 Provider,
253 Tag(String),
254}
255
256impl GroupBy {
257 pub fn to_key(&self) -> String {
258 match self {
259 GroupBy::None => "none".to_string(),
260 GroupBy::Provider => "provider".to_string(),
261 GroupBy::Tag(tag) => format!("tag:{}", tag),
262 }
263 }
264
265 pub fn from_key(s: &str) -> Self {
266 match s {
267 "none" => GroupBy::None,
268 "provider" => GroupBy::Provider,
269 s if s.starts_with("tag:") => match s.strip_prefix("tag:") {
270 Some(tag) => GroupBy::Tag(tag.to_string()),
271 _ => GroupBy::None,
272 },
273 _ => GroupBy::None,
274 }
275 }
276
277 pub fn label(&self) -> String {
278 match self {
279 GroupBy::None => "ungrouped".to_string(),
280 GroupBy::Provider => "provider".to_string(),
281 GroupBy::Tag(tag) => format!("tag: {}", tag),
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
288pub struct DeletedHost {
289 pub element: ConfigElement,
290 pub position: usize,
291}
292
293#[derive(Debug, Clone, PartialEq)]
301pub enum ProxyJumpCandidate {
302 Host {
303 alias: String,
304 hostname: String,
305 suggested: bool,
306 },
307 SectionLabel(&'static str),
308 Separator,
309}
310
311#[derive(Default)]
320pub struct HostListRenderCache {
321 pub history_width: Option<usize>,
324 pub group_alias_map: Option<HashMap<String, Vec<String>>>,
327}
328
329impl HostListRenderCache {
330 pub fn invalidate(&mut self) {
331 self.history_width = None;
332 self.group_alias_map = None;
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn default_is_empty() {
342 let s = HostState::default();
343 assert!(s.list.is_empty());
344 assert!(s.patterns.is_empty());
345 assert!(s.display_list.is_empty());
346 assert!(s.undo_stack.is_empty());
347 assert!(s.multi_select.is_empty());
348 assert!(s.group_filter.is_none());
349 assert!(s.group_tab_order.is_empty());
350 assert!(s.group_host_counts.is_empty());
351 }
352
353 #[test]
354 fn set_group_by_provider_clears_filter() {
355 let mut s = HostState {
356 group_filter: Some("acme".to_string()),
357 ..Default::default()
358 };
359 s.set_group_by(GroupBy::Provider);
360 assert!(matches!(s.group_by, GroupBy::Provider));
361 assert!(s.group_filter.is_none());
362 }
363
364 #[test]
365 fn set_group_by_none_clears_filter() {
366 let mut s = HostState {
367 group_by: GroupBy::Provider,
368 group_filter: Some("acme".to_string()),
369 ..Default::default()
370 };
371 s.set_group_by(GroupBy::None);
372 assert!(matches!(s.group_by, GroupBy::None));
373 assert!(s.group_filter.is_none());
374 }
375
376 #[test]
377 fn set_group_by_tag_clears_filter() {
378 let mut s = HostState {
379 group_filter: Some("prod".to_string()),
380 ..Default::default()
381 };
382 s.set_group_by(GroupBy::Tag("staging".to_string()));
383 match &s.group_by {
384 GroupBy::Tag(t) => assert_eq!(t, "staging"),
385 _ => panic!("expected Tag, got {:?}", s.group_by),
386 }
387 assert!(s.group_filter.is_none());
388 }
389
390 #[test]
391 fn set_group_by_overwrites_existing() {
392 let mut s = HostState {
393 group_by: GroupBy::Provider,
394 ..Default::default()
395 };
396 s.set_group_by(GroupBy::None);
397 assert!(matches!(s.group_by, GroupBy::None));
398 }
399
400 #[test]
401 fn toggle_view_mode_compact_to_detailed() {
402 let mut s = HostState::default();
403 assert_eq!(s.view_mode, ViewMode::Compact);
404 s.toggle_view_mode();
405 assert_eq!(s.view_mode, ViewMode::Detailed);
406 }
407
408 #[test]
409 fn toggle_view_mode_detailed_to_compact() {
410 let mut s = HostState {
411 view_mode: ViewMode::Detailed,
412 ..Default::default()
413 };
414 s.toggle_view_mode();
415 assert_eq!(s.view_mode, ViewMode::Compact);
416 }
417
418 #[test]
419 fn toggle_multi_select_inserts_when_absent_and_returns_true() {
420 let mut s = HostState::default();
421 let now_selected = s.toggle_multi_select(3);
422 assert!(now_selected);
423 assert!(s.multi_select.contains(&3));
424 }
425
426 #[test]
427 fn toggle_multi_select_removes_when_present_and_returns_false() {
428 let mut s = HostState::default();
429 s.multi_select.insert(3);
430 let now_selected = s.toggle_multi_select(3);
431 assert!(!now_selected);
432 assert!(!s.multi_select.contains(&3));
433 }
434
435 #[test]
436 fn toggle_multi_select_does_not_touch_other_indices() {
437 let mut s = HostState::default();
438 s.multi_select.insert(1);
439 s.multi_select.insert(2);
440 s.toggle_multi_select(3);
441 assert!(s.multi_select.contains(&1));
442 assert!(s.multi_select.contains(&2));
443 assert!(s.multi_select.contains(&3));
444 }
445}