Skip to main content

purple_ssh/app/
host_state.rs

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
9/// Host, group, sort and view state grouped off the `App` god-struct. Holds
10/// the parsed `~/.ssh/config`, the resolved host + pattern entries, the
11/// display list built from them, the render cache, the undo stack for
12/// deletions, the multi-select set for bulk snippet runs and all sort /
13/// group / view UI-state. Pure state container.
14pub 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    /// Host indices selected for multi-host snippet execution (space to toggle).
22    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    /// Currently active group filter. None = show all groups.
27    pub(in crate::app) group_filter: Option<String>,
28    /// Ordered list of group names from the current display list.
29    pub(in crate::app) group_tab_order: Vec<String>,
30    /// Host/pattern counts per group (computed before group filtering).
31    pub(in crate::app) group_host_counts: HashMap<String, usize>,
32}
33
34impl HostState {
35    /// Construct from a loaded config and pre-resolved host/pattern lists.
36    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    /// Change the group-by mode and reset any active group filter in
60    /// lockstep. Callers that change `group_by` directly would leave a
61    /// stale `group_filter` referring to a group that no longer exists.
62    pub fn set_group_by(&mut self, by: GroupBy) {
63        self.group_by = by;
64        self.group_filter = None;
65    }
66
67    /// Flip the host list between Compact and Detailed view.
68    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    /// Toggle multi-select membership for the host at `idx`. Returns
76    /// `true` when `idx` is now selected (was inserted) and `false` when
77    /// it is now unselected (was removed) so the caller can react
78    /// without re-reading the set.
79    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    /// Invalidate the host-list render cache after a mutation.
134    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    /// Drop the most recent deletion off the undo stack, if any.
147    pub fn pop_undo(&mut self) -> Option<DeletedHost> {
148        self.undo_stack.pop()
149    }
150
151    /// Clear the undo stack. Positions may have shifted after a reload.
152    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    /// Clear the multi-select set. Idempotent.
165    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    /// Advance the sort mode to the next variant in the cycle.
178    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    /// Set the group-by mode without touching the active group filter.
187    /// Use when restoring saved state. `set_group_by` resets the filter.
188    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/// An item in the display list (hosts + group headers).
244#[derive(Debug, Clone)]
245pub enum HostListItem {
246    GroupHeader(String),
247    Host { index: usize },
248    Pattern { index: usize },
249}
250
251/// View mode for the host list.
252#[derive(Debug, Clone, Copy, PartialEq)]
253pub enum ViewMode {
254    Compact,
255    Detailed,
256}
257
258/// Sort mode for the host list.
259#[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
316/// Build health summary spans: ●23 ▲2 ✖1 ○1
317/// Only includes states with count > 0. Returns empty vec if no pings.
318pub 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
325/// Build health summary spans for a subset of host aliases.
326/// Only includes states with count > 0. Returns empty vec if no pings.
327pub 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) => {} // ProxyJump, excluded
345        }
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/// Group mode for the host list.
376#[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/// Stores a deleted host for undo.
414#[derive(Debug, Clone)]
415pub struct DeletedHost {
416    pub element: ConfigElement,
417    pub position: usize,
418}
419
420/// Item in the ProxyJump picker list. Scored hosts (used elsewhere as
421/// ProxyJump, matching a jump-host name pattern, or sharing the editing
422/// host's domain suffix) are promoted above a visual separator so the
423/// likely pick is at the top and the rest stays alphabetical below.
424/// `SectionLabel` renders a non-selectable heading (e.g. "Suggestions")
425/// above the scored section. Navigation skips both `SectionLabel` and
426/// `Separator`.
427#[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/// Lazily-computed derived state that feeds the host-list renderer.
439///
440/// The renderer runs on every keystroke and every animation tick. Rebuilding
441/// these from `hosts`/`display_list`/`history` per frame allocates thousands
442/// of short-lived `String`s on hosts lists in the 500+ range. Fields are
443/// `None` when dirty; the renderer populates them on first use after an
444/// invalidation and subsequent frames reuse the values until the next
445/// mutation calls `invalidate()`.
446#[derive(Default)]
447pub struct HostListRenderCache {
448    /// Max width of formatted "last connected" strings across all hosts.
449    /// Caches the `format_time_ago` allocations.
450    pub history_width: Option<usize>,
451    /// Group-header text -> host aliases in that group. Built from
452    /// `display_list`, so invalidates on every sort/filter/reload.
453    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}