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(in crate::app) ssh_config: SshConfigFile,
16 pub(in crate::app) list: Vec<HostEntry>,
17 pub(in crate::app) patterns: Vec<PatternEntry>,
18 pub(in crate::app) display_list: Vec<HostListItem>,
19 pub(in crate::app) render_cache: HostListRenderCache,
20 pub(in crate::app) undo_stack: Vec<DeletedHost>,
21 pub(in crate::app) multi_select: HashSet<usize>,
23 pub(in crate::app) sort_mode: SortMode,
24 pub(in crate::app) group_by: GroupBy,
25 pub(in crate::app) view_mode: ViewMode,
26 pub(in crate::app) group_filter: Option<String>,
28 pub(in crate::app) group_tab_order: Vec<String>,
30 pub(in crate::app) 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 pub fn ssh_config(&self) -> &SshConfigFile {
90 &self.ssh_config
91 }
92
93 pub fn ssh_config_mut(&mut self) -> &mut SshConfigFile {
94 &mut self.ssh_config
95 }
96
97 pub fn set_ssh_config(&mut self, config: SshConfigFile) {
98 self.ssh_config = config;
99 }
100
101 pub fn list(&self) -> &Vec<HostEntry> {
102 &self.list
103 }
104
105 pub fn list_mut(&mut self) -> &mut Vec<HostEntry> {
106 &mut self.list
107 }
108
109 pub fn patterns(&self) -> &Vec<PatternEntry> {
110 &self.patterns
111 }
112
113 pub fn patterns_mut(&mut self) -> &mut Vec<PatternEntry> {
114 &mut self.patterns
115 }
116
117 pub fn display_list(&self) -> &Vec<HostListItem> {
118 &self.display_list
119 }
120
121 pub fn display_list_mut(&mut self) -> &mut Vec<HostListItem> {
122 &mut self.display_list
123 }
124
125 pub fn render_cache(&self) -> &HostListRenderCache {
126 &self.render_cache
127 }
128
129 pub fn render_cache_mut(&mut self) -> &mut HostListRenderCache {
130 &mut self.render_cache
131 }
132
133 pub fn invalidate_render_cache(&mut self) {
135 self.render_cache.invalidate();
136 }
137
138 pub fn undo_stack(&self) -> &Vec<DeletedHost> {
139 &self.undo_stack
140 }
141
142 pub fn undo_stack_mut(&mut self) -> &mut Vec<DeletedHost> {
143 &mut self.undo_stack
144 }
145
146 pub fn pop_undo(&mut self) -> Option<DeletedHost> {
148 self.undo_stack.pop()
149 }
150
151 pub fn clear_undo(&mut self) {
153 self.undo_stack.clear();
154 }
155
156 pub fn multi_select(&self) -> &HashSet<usize> {
157 &self.multi_select
158 }
159
160 pub fn multi_select_mut(&mut self) -> &mut HashSet<usize> {
161 &mut self.multi_select
162 }
163
164 pub fn clear_multi_select(&mut self) {
166 self.multi_select.clear();
167 }
168
169 pub fn sort_mode(&self) -> SortMode {
170 self.sort_mode
171 }
172
173 pub fn set_sort_mode(&mut self, mode: SortMode) {
174 self.sort_mode = mode;
175 }
176
177 pub fn advance_sort_mode(&mut self) {
179 self.sort_mode = self.sort_mode.next();
180 }
181
182 pub fn group_by(&self) -> &GroupBy {
183 &self.group_by
184 }
185
186 pub fn set_group_by_raw(&mut self, by: GroupBy) {
189 self.group_by = by;
190 }
191
192 pub fn view_mode(&self) -> ViewMode {
193 self.view_mode
194 }
195
196 pub fn set_view_mode(&mut self, mode: ViewMode) {
197 self.view_mode = mode;
198 }
199
200 pub fn group_filter(&self) -> Option<&String> {
201 self.group_filter.as_ref()
202 }
203
204 pub fn set_group_filter(&mut self, filter: Option<String>) {
205 self.group_filter = filter;
206 }
207
208 pub fn group_tab_order(&self) -> &Vec<String> {
209 &self.group_tab_order
210 }
211
212 pub fn group_host_counts(&self) -> &HashMap<String, usize> {
213 &self.group_host_counts
214 }
215}
216
217#[cfg(test)]
218impl Default for HostState {
219 fn default() -> Self {
220 Self {
221 ssh_config: SshConfigFile {
222 elements: Vec::new(),
223 path: std::path::PathBuf::new(),
224 crlf: false,
225 bom: false,
226 },
227 list: Vec::new(),
228 patterns: Vec::new(),
229 display_list: Vec::new(),
230 render_cache: HostListRenderCache::default(),
231 undo_stack: Vec::new(),
232 multi_select: HashSet::new(),
233 sort_mode: SortMode::Original,
234 group_by: GroupBy::None,
235 view_mode: ViewMode::Compact,
236 group_filter: None,
237 group_tab_order: Vec::new(),
238 group_host_counts: HashMap::new(),
239 }
240 }
241}
242
243#[derive(Debug, Clone)]
245pub enum HostListItem {
246 GroupHeader(String),
247 Host { index: usize },
248 Pattern { index: usize },
249}
250
251#[derive(Debug, Clone, Copy, PartialEq)]
253pub enum ViewMode {
254 Compact,
255 Detailed,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq)]
260pub enum SortMode {
261 Original,
262 AlphaAlias,
263 AlphaHostname,
264 Frecency,
265 MostRecent,
266 Status,
267}
268
269impl SortMode {
270 pub fn next(self) -> Self {
271 match self {
272 SortMode::Original => SortMode::AlphaAlias,
273 SortMode::AlphaAlias => SortMode::AlphaHostname,
274 SortMode::AlphaHostname => SortMode::Frecency,
275 SortMode::Frecency => SortMode::MostRecent,
276 SortMode::MostRecent => SortMode::Status,
277 SortMode::Status => SortMode::Original,
278 }
279 }
280
281 pub fn label(self) -> &'static str {
282 match self {
283 SortMode::Original => "config order",
284 SortMode::AlphaAlias => "A-Z alias",
285 SortMode::AlphaHostname => "A-Z hostname",
286 SortMode::Frecency => "most used",
287 SortMode::MostRecent => "most recent",
288 SortMode::Status => "down first",
289 }
290 }
291
292 pub fn to_key(self) -> &'static str {
293 match self {
294 SortMode::Original => "original",
295 SortMode::AlphaAlias => "alpha_alias",
296 SortMode::AlphaHostname => "alpha_hostname",
297 SortMode::Frecency => "frecency",
298 SortMode::MostRecent => "most_recent",
299 SortMode::Status => "status",
300 }
301 }
302
303 pub fn from_key(s: &str) -> Self {
304 match s {
305 "original" => SortMode::Original,
306 "alpha_alias" => SortMode::AlphaAlias,
307 "alpha_hostname" => SortMode::AlphaHostname,
308 "frecency" => SortMode::Frecency,
309 "most_recent" => SortMode::MostRecent,
310 "status" => SortMode::Status,
311 _ => SortMode::MostRecent,
312 }
313 }
314}
315
316pub fn health_summary_spans(
319 ping_status: &HashMap<String, PingStatus>,
320 hosts: &[HostEntry],
321) -> Vec<Span<'static>> {
322 health_summary_spans_for(ping_status, hosts.iter().map(|h| h.alias.as_str()))
323}
324
325pub fn health_summary_spans_for<'a>(
328 ping_status: &HashMap<String, PingStatus>,
329 aliases: impl Iterator<Item = &'a str>,
330) -> Vec<Span<'static>> {
331 if ping_status.is_empty() {
332 return vec![];
333 }
334 let mut online = 0u32;
335 let mut slow = 0u32;
336 let mut down = 0u32;
337 let mut unchecked = 0u32;
338 for alias in aliases {
339 match ping_status.get(alias) {
340 Some(PingStatus::Reachable { .. }) => online += 1,
341 Some(PingStatus::Slow { .. }) => slow += 1,
342 Some(PingStatus::Unreachable) => down += 1,
343 Some(PingStatus::Checking) | None => unchecked += 1,
344 Some(PingStatus::Skipped) => {} }
346 }
347 let mut spans = Vec::new();
348 if online > 0 {
349 spans.push(Span::styled(
350 format!("\u{25CF}{online}"),
351 theme::online_dot(),
352 ));
353 }
354 if slow > 0 {
355 if !spans.is_empty() {
356 spans.push(Span::raw(" "));
357 }
358 spans.push(Span::styled(format!("\u{25B2}{slow}"), theme::warning()));
359 }
360 if down > 0 {
361 if !spans.is_empty() {
362 spans.push(Span::raw(" "));
363 }
364 spans.push(Span::styled(format!("\u{2716}{down}"), theme::error()));
365 }
366 if unchecked > 0 {
367 if !spans.is_empty() {
368 spans.push(Span::raw(" "));
369 }
370 spans.push(Span::styled(format!("\u{25CB}{unchecked}"), theme::muted()));
371 }
372 spans
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
377pub enum GroupBy {
378 None,
379 Provider,
380 Tag(String),
381}
382
383impl GroupBy {
384 pub fn to_key(&self) -> String {
385 match self {
386 GroupBy::None => "none".to_string(),
387 GroupBy::Provider => "provider".to_string(),
388 GroupBy::Tag(tag) => format!("tag:{}", tag),
389 }
390 }
391
392 pub fn from_key(s: &str) -> Self {
393 match s {
394 "none" => GroupBy::None,
395 "provider" => GroupBy::Provider,
396 s if s.starts_with("tag:") => match s.strip_prefix("tag:") {
397 Some(tag) => GroupBy::Tag(tag.to_string()),
398 _ => GroupBy::None,
399 },
400 _ => GroupBy::None,
401 }
402 }
403
404 pub fn label(&self) -> String {
405 match self {
406 GroupBy::None => "ungrouped".to_string(),
407 GroupBy::Provider => "provider".to_string(),
408 GroupBy::Tag(tag) => format!("tag: {}", tag),
409 }
410 }
411}
412
413#[derive(Debug, Clone)]
415pub struct DeletedHost {
416 pub element: ConfigElement,
417 pub position: usize,
418}
419
420#[derive(Debug, Clone, PartialEq)]
428pub enum ProxyJumpCandidate {
429 Host {
430 alias: String,
431 hostname: String,
432 suggested: bool,
433 },
434 SectionLabel(&'static str),
435 Separator,
436}
437
438#[derive(Default)]
447pub struct HostListRenderCache {
448 pub history_width: Option<usize>,
451 pub group_alias_map: Option<HashMap<String, Vec<String>>>,
454}
455
456impl HostListRenderCache {
457 pub fn invalidate(&mut self) {
458 self.history_width = None;
459 self.group_alias_map = None;
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn default_is_empty() {
469 let s = HostState::default();
470 assert!(s.list.is_empty());
471 assert!(s.patterns.is_empty());
472 assert!(s.display_list.is_empty());
473 assert!(s.undo_stack.is_empty());
474 assert!(s.multi_select.is_empty());
475 assert!(s.group_filter.is_none());
476 assert!(s.group_tab_order.is_empty());
477 assert!(s.group_host_counts.is_empty());
478 }
479
480 #[test]
481 fn set_group_by_provider_clears_filter() {
482 let mut s = HostState {
483 group_filter: Some("acme".to_string()),
484 ..Default::default()
485 };
486 s.set_group_by(GroupBy::Provider);
487 assert!(matches!(s.group_by, GroupBy::Provider));
488 assert!(s.group_filter.is_none());
489 }
490
491 #[test]
492 fn set_group_by_none_clears_filter() {
493 let mut s = HostState {
494 group_by: GroupBy::Provider,
495 group_filter: Some("acme".to_string()),
496 ..Default::default()
497 };
498 s.set_group_by(GroupBy::None);
499 assert!(matches!(s.group_by, GroupBy::None));
500 assert!(s.group_filter.is_none());
501 }
502
503 #[test]
504 fn set_group_by_tag_clears_filter() {
505 let mut s = HostState {
506 group_filter: Some("prod".to_string()),
507 ..Default::default()
508 };
509 s.set_group_by(GroupBy::Tag("staging".to_string()));
510 match &s.group_by {
511 GroupBy::Tag(t) => assert_eq!(t, "staging"),
512 _ => panic!("expected Tag, got {:?}", s.group_by),
513 }
514 assert!(s.group_filter.is_none());
515 }
516
517 #[test]
518 fn set_group_by_overwrites_existing() {
519 let mut s = HostState {
520 group_by: GroupBy::Provider,
521 ..Default::default()
522 };
523 s.set_group_by(GroupBy::None);
524 assert!(matches!(s.group_by, GroupBy::None));
525 }
526
527 #[test]
528 fn toggle_view_mode_compact_to_detailed() {
529 let mut s = HostState::default();
530 assert_eq!(s.view_mode, ViewMode::Compact);
531 s.toggle_view_mode();
532 assert_eq!(s.view_mode, ViewMode::Detailed);
533 }
534
535 #[test]
536 fn toggle_view_mode_detailed_to_compact() {
537 let mut s = HostState {
538 view_mode: ViewMode::Detailed,
539 ..Default::default()
540 };
541 s.toggle_view_mode();
542 assert_eq!(s.view_mode, ViewMode::Compact);
543 }
544
545 #[test]
546 fn toggle_multi_select_inserts_when_absent_and_returns_true() {
547 let mut s = HostState::default();
548 let now_selected = s.toggle_multi_select(3);
549 assert!(now_selected);
550 assert!(s.multi_select.contains(&3));
551 }
552
553 #[test]
554 fn toggle_multi_select_removes_when_present_and_returns_false() {
555 let mut s = HostState::default();
556 s.multi_select.insert(3);
557 let now_selected = s.toggle_multi_select(3);
558 assert!(!now_selected);
559 assert!(!s.multi_select.contains(&3));
560 }
561
562 #[test]
563 fn toggle_multi_select_does_not_touch_other_indices() {
564 let mut s = HostState::default();
565 s.multi_select.insert(1);
566 s.multi_select.insert(2);
567 s.toggle_multi_select(3);
568 assert!(s.multi_select.contains(&1));
569 assert!(s.multi_select.contains(&2));
570 assert!(s.multi_select.contains(&3));
571 }
572
573 #[test]
574 fn advance_sort_mode_steps_to_next_variant() {
575 let mut s = HostState::default();
576 assert_eq!(s.sort_mode, SortMode::Original);
577 s.advance_sort_mode();
578 assert_eq!(s.sort_mode, SortMode::AlphaAlias);
579 }
580
581 #[test]
582 fn advance_sort_mode_wraps_from_last_to_first() {
583 let mut s = HostState {
584 sort_mode: SortMode::Status,
585 ..Default::default()
586 };
587 s.advance_sort_mode();
588 assert_eq!(s.sort_mode, SortMode::Original);
589 }
590
591 #[test]
592 fn set_group_by_raw_keeps_active_filter() {
593 let mut s = HostState {
594 group_filter: Some("acme".to_string()),
595 ..Default::default()
596 };
597 s.set_group_by_raw(GroupBy::Provider);
598 assert!(matches!(s.group_by, GroupBy::Provider));
599 assert_eq!(s.group_filter.as_deref(), Some("acme"));
600 }
601
602 #[test]
603 fn clear_multi_select_empties_the_set() {
604 let mut s = HostState::default();
605 s.multi_select.insert(1);
606 s.multi_select.insert(2);
607 s.clear_multi_select();
608 assert!(s.multi_select.is_empty());
609 }
610
611 #[test]
612 fn pop_undo_returns_most_recent_deletion() {
613 let mut s = HostState::default();
614 s.undo_stack.push(DeletedHost {
615 element: ConfigElement::GlobalLine(String::new()),
616 position: 0,
617 });
618 s.undo_stack.push(DeletedHost {
619 element: ConfigElement::GlobalLine(String::new()),
620 position: 7,
621 });
622 let popped = s.pop_undo().expect("undo entry present");
623 assert_eq!(popped.position, 7);
624 assert_eq!(s.undo_stack.len(), 1);
625 }
626
627 #[test]
628 fn pop_undo_returns_none_when_empty() {
629 let mut s = HostState::default();
630 assert!(s.pop_undo().is_none());
631 }
632
633 #[test]
634 fn clear_undo_empties_the_stack() {
635 let mut s = HostState::default();
636 s.undo_stack.push(DeletedHost {
637 element: ConfigElement::GlobalLine(String::new()),
638 position: 0,
639 });
640 s.clear_undo();
641 assert!(s.undo_stack.is_empty());
642 }
643
644 #[test]
645 fn invalidate_render_cache_clears_cached_fields() {
646 let mut s = HostState::default();
647 s.render_cache.history_width = Some(12);
648 s.render_cache.group_alias_map = Some(HashMap::new());
649 s.invalidate_render_cache();
650 assert!(s.render_cache.history_width.is_none());
651 assert!(s.render_cache.group_alias_map.is_none());
652 }
653}