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 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    /// Host indices selected for multi-host snippet execution (space to toggle).
22    pub multi_select: HashSet<usize>,
23    pub sort_mode: SortMode,
24    pub group_by: GroupBy,
25    pub view_mode: ViewMode,
26    /// Currently active group filter. None = show all groups.
27    pub group_filter: Option<String>,
28    /// Ordered list of group names from the current display list.
29    pub group_tab_order: Vec<String>,
30    /// Host/pattern counts per group (computed before group filtering).
31    pub 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
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/// An item in the display list (hosts + group headers).
117#[derive(Debug, Clone)]
118pub enum HostListItem {
119    GroupHeader(String),
120    Host { index: usize },
121    Pattern { index: usize },
122}
123
124/// View mode for the host list.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub enum ViewMode {
127    Compact,
128    Detailed,
129}
130
131/// Sort mode for the host list.
132#[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
189/// Build health summary spans: ●23 ▲2 ✖1 ○1
190/// Only includes states with count > 0. Returns empty vec if no pings.
191pub 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
198/// Build health summary spans for a subset of host aliases.
199/// Only includes states with count > 0. Returns empty vec if no pings.
200pub 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) => {} // ProxyJump, excluded
218        }
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/// Group mode for the host list.
249#[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/// Stores a deleted host for undo.
287#[derive(Debug, Clone)]
288pub struct DeletedHost {
289    pub element: ConfigElement,
290    pub position: usize,
291}
292
293/// Item in the ProxyJump picker list. Scored hosts (used elsewhere as
294/// ProxyJump, matching a jump-host name pattern, or sharing the editing
295/// host's domain suffix) are promoted above a visual separator so the
296/// likely pick is at the top and the rest stays alphabetical below.
297/// `SectionLabel` renders a non-selectable heading (e.g. "Suggestions")
298/// above the scored section. Navigation skips both `SectionLabel` and
299/// `Separator`.
300#[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/// Lazily-computed derived state that feeds the host-list renderer.
312///
313/// The renderer runs on every keystroke and every animation tick. Rebuilding
314/// these from `hosts`/`display_list`/`history` per frame allocates thousands
315/// of short-lived `String`s on hosts lists in the 500+ range. Fields are
316/// `None` when dirty; the renderer populates them on first use after an
317/// invalidation and subsequent frames reuse the values until the next
318/// mutation calls `invalidate()`.
319#[derive(Default)]
320pub struct HostListRenderCache {
321    /// Max width of formatted "last connected" strings across all hosts.
322    /// Caches the `format_time_ago` allocations.
323    pub history_width: Option<usize>,
324    /// Group-header text -> host aliases in that group. Built from
325    /// `display_list`, so invalidates on every sort/filter/reload.
326    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}