Skip to main content

purple_ssh/app/
selection.rs

1//! Selection and navigation helpers: keys, tags, tunnels, snippets, and the
2//! background tunnel polling that updates status when active tunnels exit.
3
4use std::path::Path;
5
6use ratatui::widgets::ListState;
7
8use super::{
9    BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, HostListItem,
10    ProxyJumpCandidate, Screen,
11};
12use crate::app::App;
13use crate::ssh_config::model::{HostEntry, PatternEntry};
14use crate::ssh_keys;
15
16impl App {
17    /// Transition to a new screen. Logs the transition at debug level for
18    /// support-bundle traceability. Callers should prefer this over direct
19    /// `app.screen = ...` assignment.
20    pub fn set_screen(&mut self, screen: Screen) {
21        if self.screen != screen {
22            log::debug!(
23                "screen: {} → {}",
24                self.screen.variant_name(),
25                screen.variant_name()
26            );
27        }
28        self.screen = screen;
29    }
30
31    /// Open the Help overlay over the current screen, moving the previous
32    /// screen into the `return_screen` slot instead of cloning it. No-op
33    /// when Help is already open.
34    pub fn push_help_overlay(&mut self) {
35        if matches!(self.screen, Screen::Help { .. }) {
36            return;
37        }
38        log::debug!("screen: {} → Help", self.screen.variant_name());
39        let old = std::mem::replace(&mut self.screen, Screen::HostList);
40        self.screen = Screen::Help {
41            return_screen: Box::new(old),
42        };
43    }
44
45    /// Close the Help overlay and restore the previous screen. The boxed
46    /// screen is moved back into place rather than cloned. No-op when the
47    /// current screen is not Help.
48    pub fn pop_help_overlay(&mut self) {
49        let returned = {
50            let Screen::Help { return_screen } = &mut self.screen else {
51                return;
52            };
53            std::mem::replace(&mut **return_screen, Screen::HostList)
54        };
55        log::debug!("screen: Help → {}", returned.variant_name());
56        self.screen = returned;
57    }
58
59    /// Cycle to the next top page. Logs the transition so Tab-cycle
60    /// confusion ("I keep landing on the wrong page after Tab") leaves a
61    /// breadcrumb in `~/.purple/purple.log`. Callers should prefer this
62    /// over direct `app.top_page = app.top_page.next()` assignment.
63    pub fn cycle_top_page_next(&mut self) {
64        let old = self.top_page;
65        self.top_page = self.top_page.next();
66        log::debug!("[purple] top_page: {:?} → {:?} (Tab)", old, self.top_page);
67    }
68
69    /// Cycle to the previous top page. See `cycle_top_page_next`.
70    pub fn cycle_top_page_prev(&mut self) {
71        let old = self.top_page;
72        self.top_page = self.top_page.prev();
73        log::debug!(
74            "[purple] top_page: {:?} → {:?} (Shift+Tab)",
75            old,
76            self.top_page
77        );
78    }
79
80    /// Get the host index from the currently selected display list item.
81    pub fn selected_host_index(&self) -> Option<usize> {
82        if self.search.query.is_some() {
83            // In search mode, list_state indexes into filtered_indices
84            let sel = self.ui.list_state.selected()?;
85            self.search.filtered_indices.get(sel).copied()
86        } else {
87            // In normal mode, list_state indexes into display_list
88            let sel = self.ui.list_state.selected()?;
89            match self.hosts_state.display_list.get(sel) {
90                Some(HostListItem::Host { index }) => Some(*index),
91                _ => None,
92            }
93        }
94    }
95
96    /// Get the currently selected host entry.
97    pub fn selected_host(&self) -> Option<&HostEntry> {
98        self.selected_host_index()
99            .and_then(|i| self.hosts_state.list.get(i))
100    }
101
102    /// Get the currently selected pattern entry (if a pattern is selected).
103    pub fn selected_pattern(&self) -> Option<&PatternEntry> {
104        if self.search.query.is_some() {
105            let sel = self.ui.list_state.selected()?;
106            let host_count = self.search.filtered_indices.len();
107            if sel >= host_count {
108                let pattern_idx = sel - host_count;
109                return self
110                    .search
111                    .filtered_pattern_indices
112                    .get(pattern_idx)
113                    .and_then(|&i| self.hosts_state.patterns.get(i));
114            }
115            return None;
116        }
117        let sel = self.ui.list_state.selected()?;
118        match self.hosts_state.display_list.get(sel) {
119            Some(HostListItem::Pattern { index }) => self.hosts_state.patterns.get(*index),
120            _ => None,
121        }
122    }
123
124    /// Check if the currently selected item is a pattern.
125    pub fn is_pattern_selected(&self) -> bool {
126        if self.search.query.is_some() {
127            let Some(sel) = self.ui.list_state.selected() else {
128                return false;
129            };
130            let total =
131                self.search.filtered_indices.len() + self.search.filtered_pattern_indices.len();
132            return sel >= self.search.filtered_indices.len() && sel < total;
133        }
134        let Some(sel) = self.ui.list_state.selected() else {
135            return false;
136        };
137        matches!(
138            self.hosts_state.display_list.get(sel),
139            Some(HostListItem::Pattern { .. })
140        )
141    }
142
143    /// Move selection up, skipping group headers.
144    pub fn select_prev(&mut self) {
145        self.ui.detail_scroll = 0;
146        if self.search.query.is_some() {
147            let total =
148                self.search.filtered_indices.len() + self.search.filtered_pattern_indices.len();
149            super::cycle_selection(&mut self.ui.list_state, total, false);
150        } else {
151            self.select_prev_in_display_list();
152        }
153    }
154
155    /// Move selection down, skipping group headers.
156    pub fn select_next(&mut self) {
157        self.ui.detail_scroll = 0;
158        if self.search.query.is_some() {
159            let total =
160                self.search.filtered_indices.len() + self.search.filtered_pattern_indices.len();
161            super::cycle_selection(&mut self.ui.list_state, total, true);
162        } else {
163            self.select_next_in_display_list();
164        }
165    }
166
167    fn select_next_in_display_list(&mut self) {
168        if self.hosts_state.display_list.is_empty() {
169            return;
170        }
171        let len = self.hosts_state.display_list.len();
172        let current = self.ui.list_state.selected().unwrap_or(0);
173        // Find next selectable item after current (always skip headers)
174        for offset in 1..=len {
175            let idx = (current + offset) % len;
176            if matches!(
177                &self.hosts_state.display_list[idx],
178                HostListItem::Host { .. } | HostListItem::Pattern { .. }
179            ) {
180                self.ui.list_state.select(Some(idx));
181                return;
182            }
183        }
184    }
185
186    fn select_prev_in_display_list(&mut self) {
187        if self.hosts_state.display_list.is_empty() {
188            return;
189        }
190        let len = self.hosts_state.display_list.len();
191        let current = self.ui.list_state.selected().unwrap_or(0);
192        // Find prev selectable item before current (always skip headers)
193        for offset in 1..=len {
194            let idx = (current + len - offset) % len;
195            if matches!(
196                &self.hosts_state.display_list[idx],
197                HostListItem::Host { .. } | HostListItem::Pattern { .. }
198            ) {
199                self.ui.list_state.select(Some(idx));
200                return;
201            }
202        }
203    }
204
205    /// Page down in the host list, skipping group headers when ungrouped.
206    pub fn page_down_host(&mut self) {
207        self.ui.detail_scroll = 0;
208        const PAGE_SIZE: usize = 10;
209        if self.search.query.is_some() {
210            super::page_down(
211                &mut self.ui.list_state,
212                self.search.filtered_indices.len(),
213                PAGE_SIZE,
214            );
215        } else {
216            let current = self.ui.list_state.selected().unwrap_or(0);
217            let mut target = current;
218            let mut items_skipped = 0;
219            let len = self.hosts_state.display_list.len();
220            for i in (current + 1)..len {
221                if matches!(
222                    self.hosts_state.display_list[i],
223                    HostListItem::Host { .. } | HostListItem::Pattern { .. }
224                ) {
225                    target = i;
226                    items_skipped += 1;
227                    if items_skipped >= PAGE_SIZE {
228                        break;
229                    }
230                }
231            }
232            if target != current {
233                self.ui.list_state.select(Some(target));
234            }
235        }
236    }
237
238    /// Page up in the host list, skipping group headers.
239    pub fn page_up_host(&mut self) {
240        self.ui.detail_scroll = 0;
241        const PAGE_SIZE: usize = 10;
242        if self.search.query.is_some() {
243            super::page_up(
244                &mut self.ui.list_state,
245                self.search.filtered_indices.len(),
246                PAGE_SIZE,
247            );
248        } else {
249            let current = self.ui.list_state.selected().unwrap_or(0);
250            let mut target = current;
251            let mut items_skipped = 0;
252            for i in (0..current).rev() {
253                if matches!(
254                    self.hosts_state.display_list[i],
255                    HostListItem::Host { .. } | HostListItem::Pattern { .. }
256                ) {
257                    target = i;
258                    items_skipped += 1;
259                    if items_skipped >= PAGE_SIZE {
260                        break;
261                    }
262                }
263            }
264            if target != current {
265                self.ui.list_state.select(Some(target));
266            }
267        }
268    }
269    pub fn scan_keys(&mut self) {
270        let paths = self.env().paths().cloned();
271        let ssh_dir = paths.as_ref().map(crate::runtime::env::Paths::ssh_dir);
272        if let Some(ssh_dir) = ssh_dir {
273            self.keys.list = ssh_keys::discover_keys(
274                paths.as_ref(),
275                Path::new(&ssh_dir),
276                &self.hosts_state.list,
277            );
278            if !self.keys.list.is_empty() && self.keys.list_state.selected().is_none() {
279                self.keys.list_state.select(Some(0));
280            }
281            self.reload.keys_dir_mtime = crate::app::reload_state::get_mtime(&ssh_dir);
282            self.reload.key_file_mtimes =
283                crate::app::reload_state::snapshot_key_mtimes(&ssh_dir, &self.keys.list);
284        }
285    }
286
287    /// Move key list selection up.
288    pub fn select_prev_key(&mut self) {
289        super::cycle_selection(&mut self.keys.list_state, self.keys.list.len(), false);
290    }
291
292    /// Move key list selection down.
293    pub fn select_next_key(&mut self) {
294        super::cycle_selection(&mut self.keys.list_state, self.keys.list.len(), true);
295    }
296
297    /// Move key picker selection up.
298    pub fn select_prev_picker_key(&mut self) {
299        super::cycle_selection(&mut self.ui.key_picker.list, self.keys.list.len(), false);
300    }
301
302    /// Move key picker selection down.
303    pub fn select_next_picker_key(&mut self) {
304        super::cycle_selection(&mut self.ui.key_picker.list, self.keys.list.len(), true);
305    }
306
307    /// Move password picker selection up.
308    pub fn select_prev_password_source(&mut self) {
309        super::cycle_selection(
310            &mut self.ui.password_picker.list,
311            crate::askpass::PASSWORD_SOURCES.len(),
312            false,
313        );
314    }
315
316    /// Move password picker selection down.
317    pub fn select_next_password_source(&mut self) {
318        super::cycle_selection(
319            &mut self.ui.password_picker.list,
320            crate::askpass::PASSWORD_SOURCES.len(),
321            true,
322        );
323    }
324
325    /// Get hosts available as ProxyJump targets (excludes the host being
326    /// edited), ranked so likely jump hosts appear on top. Ranking combines
327    /// three signals: usage count as ProxyJump on other hosts, alias or
328    /// hostname matching a jump-host keyword (`jump`, `bastion`, `gateway`,
329    /// `proxy`, `gw`), and sharing the last two domain labels with the
330    /// hostname of the host being edited. Items with a non-zero score are
331    /// grouped in a "suggested" section above a visual `Separator`. The
332    /// remaining items are listed alphabetically below. If no item scores,
333    /// the full list is alphabetical with no separator.
334    pub fn proxyjump_candidates(&self) -> Vec<ProxyJumpCandidate> {
335        let editing_alias = match &self.screen {
336            Screen::EditHost { alias, .. } => Some(alias.as_str()),
337            _ => None,
338        };
339        let editing_hostname = match &self.screen {
340            Screen::EditHost { alias, .. } => self
341                .hosts_state
342                .list
343                .iter()
344                .find(|h| h.alias == *alias)
345                .map(|h| h.hostname.as_str()),
346            _ => None,
347        };
348        let editing_suffix = editing_hostname.and_then(domain_suffix);
349
350        let usage_counts = proxyjump_usage_counts(&self.hosts_state.list, editing_alias);
351        let mut scored = score_proxyjump_candidates(
352            &self.hosts_state.list,
353            editing_alias,
354            editing_suffix.as_deref(),
355            &usage_counts,
356        );
357
358        // Top-3 suggestions: score > 0, sorted by score desc then alias asc.
359        scored.sort_by(|(sa, a), (sb, b)| sb.cmp(sa).then_with(|| a.alias.cmp(&b.alias)));
360        let suggested: Vec<&HostEntry> = scored
361            .iter()
362            .filter(|(s, _)| *s > 0)
363            .take(3)
364            .map(|(_, h)| *h)
365            .collect();
366        let suggested_aliases: std::collections::HashSet<&str> =
367            suggested.iter().map(|h| h.alias.as_str()).collect();
368
369        // Rest: everything not suggested, alphabetical by alias.
370        scored.sort_by(|(_, a), (_, b)| a.alias.cmp(&b.alias));
371        let rest: Vec<&HostEntry> = scored
372            .into_iter()
373            .map(|(_, h)| h)
374            .filter(|h| !suggested_aliases.contains(h.alias.as_str()))
375            .collect();
376
377        build_proxyjump_items(&suggested, &rest)
378    }
379
380    /// Find the first selectable (non-separator) index in the ProxyJump
381    /// picker, or None if the list has no hosts.
382    pub fn proxyjump_first_host_index(&self) -> Option<usize> {
383        self.proxyjump_candidates()
384            .iter()
385            .position(|c| matches!(c, ProxyJumpCandidate::Host { .. }))
386    }
387
388    /// Move proxyjump picker selection up, skipping separators.
389    pub fn select_prev_proxyjump(&mut self) {
390        step_proxyjump_selection(self, false);
391    }
392
393    /// Move proxyjump picker selection down, skipping separators.
394    pub fn select_next_proxyjump(&mut self) {
395        step_proxyjump_selection(self, true);
396    }
397
398    /// Collect unique Vault SSH roles from all hosts and providers, sorted.
399    pub fn vault_role_candidates(&self) -> Vec<String> {
400        let mut seen = std::collections::HashSet::new();
401        let mut roles = Vec::new();
402        for host in &self.hosts_state.list {
403            if let Some(ref role) = host.vault_ssh {
404                if seen.insert(role.clone()) {
405                    roles.push(role.clone());
406                }
407            }
408        }
409        // Also collect from provider configs.
410        for section in &self.providers.config.sections {
411            let role = section.vault_role.trim();
412            if !role.is_empty() && seen.insert(role.to_string()) {
413                roles.push(role.to_string());
414            }
415        }
416        roles.sort();
417        roles
418    }
419
420    /// Move vault role picker selection up.
421    pub fn select_prev_vault_role(&mut self) {
422        let len = self.vault_role_candidates().len();
423        super::cycle_selection(&mut self.ui.vault_role_picker.list, len, false);
424    }
425
426    /// Move vault role picker selection down.
427    pub fn select_next_vault_role(&mut self) {
428        let len = self.vault_role_candidates().len();
429        super::cycle_selection(&mut self.ui.vault_role_picker.list, len, true);
430    }
431
432    /// Collect all unique tags from hosts, sorted alphabetically.
433    pub fn collect_unique_tags(&self) -> Vec<String> {
434        let mut seen = std::collections::HashSet::new();
435        let mut tags = Vec::new();
436        let mut has_stale = false;
437        let mut has_vault_ssh = false;
438        let mut has_vault_kv = false;
439        for host in &self.hosts_state.list {
440            for tag in host.provider_tags.iter().chain(host.tags.iter()) {
441                if seen.insert(tag.clone()) {
442                    tags.push(tag.clone());
443                }
444            }
445            if let Some(ref provider) = host.provider {
446                if seen.insert(provider.clone()) {
447                    tags.push(provider.clone());
448                }
449            }
450            if host.stale.is_some() {
451                has_stale = true;
452            }
453            if crate::vault_ssh::resolve_vault_role(
454                host.vault_ssh.as_deref(),
455                host.provider.as_deref(),
456                host.provider_label.as_deref(),
457                &self.providers.config,
458            )
459            .is_some()
460            {
461                has_vault_ssh = true;
462            }
463            if host
464                .askpass
465                .as_deref()
466                .map(|s| s.starts_with("vault:"))
467                .unwrap_or(false)
468            {
469                has_vault_kv = true;
470            }
471        }
472        for pattern in &self.hosts_state.patterns {
473            for tag in &pattern.tags {
474                if seen.insert(tag.clone()) {
475                    tags.push(tag.clone());
476                }
477            }
478        }
479        if has_stale && seen.insert("stale".to_string()) {
480            tags.push("stale".to_string());
481        }
482        if !has_vault_ssh {
483            for section in &self.providers.config.sections {
484                if !section.vault_role.is_empty() {
485                    has_vault_ssh = true;
486                    break;
487                }
488            }
489        }
490        if has_vault_ssh && seen.insert("vault-ssh".to_string()) {
491            tags.push("vault-ssh".to_string());
492        }
493        if has_vault_kv && seen.insert("vault-kv".to_string()) {
494            tags.push("vault-kv".to_string());
495        }
496        tags.sort_by_cached_key(|a| a.to_lowercase());
497        tags
498    }
499
500    /// Open the bulk tag editor for every host currently in `multi_select`.
501    /// Returns false (and leaves the screen untouched) when the selection
502    /// is empty or contains only pattern entries — callers can then fall
503    /// back to single-host tag editing or show a status message.
504    ///
505    /// Hosts that live in an Include file are still listed in `aliases` but
506    /// get surfaced via `skipped_included`. `bulk_tag_apply` honours that
507    /// split so included hosts are never mutated in place.
508    pub fn open_bulk_tag_editor(&mut self) -> bool {
509        let mut aliases: Vec<String> = Vec::new();
510        let mut skipped: Vec<String> = Vec::new();
511        let mut alias_set: std::collections::HashSet<String> = std::collections::HashSet::new();
512        for &idx in &self.hosts_state.multi_select {
513            if let Some(host) = self.hosts_state.list.get(idx) {
514                if !alias_set.insert(host.alias.clone()) {
515                    continue;
516                }
517                if host.source_file.is_some() {
518                    skipped.push(host.alias.clone());
519                }
520                aliases.push(host.alias.clone());
521            }
522        }
523        if aliases.is_empty() {
524            return false;
525        }
526        aliases.sort();
527        skipped.sort();
528
529        // Collect candidate tags: union of all user tags across the whole
530        // config. This lets users apply an existing tag that none of the
531        // selected hosts have yet (the common "tag a new batch with prod"
532        // case).
533        let mut candidate_tags: std::collections::BTreeSet<String> =
534            std::collections::BTreeSet::new();
535        for host in &self.hosts_state.list {
536            for tag in &host.tags {
537                candidate_tags.insert(tag.clone());
538            }
539        }
540        for pattern in &self.hosts_state.patterns {
541            for tag in &pattern.tags {
542                candidate_tags.insert(tag.clone());
543            }
544        }
545
546        let selected_set: std::collections::HashSet<&str> =
547            aliases.iter().map(|s| s.as_str()).collect();
548        let rows: Vec<BulkTagRow> = candidate_tags
549            .into_iter()
550            .map(|tag| {
551                let initial_count = self
552                    .hosts_state
553                    .list
554                    .iter()
555                    .filter(|h| selected_set.contains(h.alias.as_str()))
556                    .filter(|h| h.tags.iter().any(|t| t == &tag))
557                    .count();
558                BulkTagRow {
559                    tag,
560                    initial_count,
561                    action: BulkTagAction::Leave,
562                }
563            })
564            .collect();
565
566        // Snapshot baseline actions for the dirty-check on Esc. Every row
567        // starts at `Leave`; the snapshot is the same length as `rows` so
568        // `is_dirty` short-circuits before scanning when nothing has changed.
569        let initial_actions: Vec<BulkTagAction> = rows.iter().map(|r| r.action).collect();
570        self.forms.bulk_tag_editor = BulkTagEditorState {
571            rows,
572            aliases,
573            skipped_included: skipped,
574            new_tag_input: None,
575            new_tag_cursor: 0,
576            initial_actions,
577        };
578        self.ui.bulk_tag_editor_state = ListState::default();
579        if !self.forms.bulk_tag_editor.rows.is_empty() {
580            self.ui.bulk_tag_editor_state.select(Some(0));
581        }
582        self.set_screen(Screen::BulkTagEditor);
583        true
584    }
585
586    /// Move bulk tag editor selection down.
587    pub fn bulk_tag_editor_next(&mut self) {
588        super::cycle_selection(
589            &mut self.ui.bulk_tag_editor_state,
590            self.forms.bulk_tag_editor.rows.len(),
591            true,
592        );
593    }
594
595    /// Move bulk tag editor selection up.
596    pub fn bulk_tag_editor_prev(&mut self) {
597        super::cycle_selection(
598            &mut self.ui.bulk_tag_editor_state,
599            self.forms.bulk_tag_editor.rows.len(),
600            false,
601        );
602    }
603
604    /// Cycle the action on the currently selected row:
605    /// `Leave` → `AddToAll` → `RemoveFromAll` → `Leave`.
606    pub fn bulk_tag_editor_cycle_current(&mut self) {
607        super::bulk_tag_cycle_current(&self.ui, &mut self.forms);
608    }
609
610    /// Append a freshly typed tag to the row list. The new row is marked
611    /// `AddToAll` so the user's intent ("add this new tag to all selected
612    /// hosts") is preserved without a second keystroke. No-op for empty
613    /// input or duplicate tag names.
614    pub fn bulk_tag_editor_commit_new_tag(&mut self) {
615        super::bulk_tag_commit_new_tag(&mut self.ui, &mut self.forms);
616    }
617
618    /// Apply all pending actions from the bulk tag editor. Leaves the
619    /// config untouched (and returns an error) if the write fails so the
620    /// user can retry without losing state. On success, hosts are reloaded
621    /// (which clears `multi_select`).
622    pub fn bulk_tag_apply(&mut self) -> Result<BulkTagApplyResult, String> {
623        // Compute + config write run on the host and form slices alone; the
624        // returned result drives the caller's control flow inline. Only the
625        // post-write whole-App tails (mtime refresh, reload) need full App,
626        // and only when something actually changed on disk.
627        let result = super::apply_bulk_tags(&mut self.hosts_state, &mut self.forms)?;
628        if result.changed_hosts > 0 {
629            self.update_last_modified();
630            self.reload_hosts();
631        }
632        Ok(result)
633    }
634
635    /// Open the tag picker overlay.
636    pub fn open_tag_picker(&mut self) {
637        self.tags.list = self.collect_unique_tags();
638        self.ui.tag_picker_state = ListState::default();
639        if !self.tags.list.is_empty() {
640            self.ui.tag_picker_state.select(Some(0));
641        }
642        self.set_screen(Screen::TagPicker);
643    }
644
645    /// Move tag picker selection up.
646    pub fn select_prev_tag(&mut self) {
647        super::cycle_selection(&mut self.ui.tag_picker_state, self.tags.list.len(), false);
648    }
649
650    /// Move tag picker selection down.
651    pub fn select_next_tag(&mut self) {
652        super::cycle_selection(&mut self.ui.tag_picker_state, self.tags.list.len(), true);
653    }
654
655    /// Load tunnel directives for a host alias.
656    /// Uses find_tunnel_directives for Include-aware, multi-pattern host lookup.
657    pub fn refresh_tunnel_list(&mut self, alias: &str) {
658        self.tunnels
659            .load_directives(&self.hosts_state.ssh_config, alias);
660    }
661
662    /// Move tunnel list selection up.
663    pub fn select_prev_tunnel(&mut self) {
664        super::cycle_selection(
665            &mut self.ui.tunnel_list_state,
666            self.tunnels.list.len(),
667            false,
668        );
669    }
670
671    /// Move tunnel list selection down.
672    pub fn select_next_tunnel(&mut self) {
673        super::cycle_selection(
674            &mut self.ui.tunnel_list_state,
675            self.tunnels.list.len(),
676            true,
677        );
678    }
679
680    /// Move snippet picker selection up.
681    pub fn select_prev_snippet(&mut self) {
682        super::cycle_selection(
683            &mut self.ui.snippet_picker_state,
684            self.snippets.store.snippets.len(),
685            false,
686        );
687    }
688
689    /// Move snippet picker selection down.
690    pub fn select_next_snippet(&mut self) {
691        super::cycle_selection(
692            &mut self.ui.snippet_picker_state,
693            self.snippets.store.snippets.len(),
694            true,
695        );
696    }
697
698    /// Poll active tunnels for exit status. Returns messages for any that exited.
699    /// Move selection to the next non-header item.
700    pub fn select_next_skipping_headers(&mut self) {
701        let current = self.ui.list_state.selected().unwrap_or(0);
702        for i in (current + 1)..self.hosts_state.display_list.len() {
703            if !matches!(
704                self.hosts_state.display_list[i],
705                HostListItem::GroupHeader(_)
706            ) {
707                self.ui.list_state.select(Some(i));
708                return;
709            }
710        }
711    }
712
713    /// Move selection to the previous non-header item.
714    pub fn select_prev_skipping_headers(&mut self) {
715        let current = self.ui.list_state.selected().unwrap_or(0);
716        for i in (0..current).rev() {
717            if !matches!(
718                self.hosts_state.display_list[i],
719                HostListItem::GroupHeader(_)
720            ) {
721                self.ui.list_state.select(Some(i));
722                return;
723            }
724        }
725    }
726}
727
728const JUMP_KEYWORDS: &[&str] = &["jump", "bastion", "gateway", "proxy", "gw"];
729
730/// Count how often each alias appears as a ProxyJump hop across all hosts,
731/// excluding the host currently being edited.
732fn proxyjump_usage_counts(
733    hosts: &[HostEntry],
734    editing_alias: Option<&str>,
735) -> std::collections::HashMap<String, u32> {
736    let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
737    for h in hosts {
738        if h.proxy_jump.is_empty() || editing_alias == Some(h.alias.as_str()) {
739            continue;
740        }
741        for hop in parse_proxy_jump_hops(&h.proxy_jump) {
742            *counts.entry(hop).or_insert(0) += 1;
743        }
744    }
745    counts
746}
747
748/// Score each host as a ProxyJump candidate. Excludes the host being edited.
749/// Score = usage * 10 + keyword_hit * 5 + shared_domain_suffix * 3.
750fn score_proxyjump_candidates<'a>(
751    hosts: &'a [HostEntry],
752    editing_alias: Option<&str>,
753    editing_suffix: Option<&str>,
754    usage_counts: &std::collections::HashMap<String, u32>,
755) -> Vec<(u32, &'a HostEntry)> {
756    hosts
757        .iter()
758        .filter(|h| editing_alias.is_none_or(|a| h.alias != a))
759        .map(|h| {
760            let usage = usage_counts.get(&h.alias).copied().unwrap_or(0);
761            let kw = has_jump_keyword(&h.alias, &h.hostname);
762            let same = editing_suffix
763                .and_then(|suf| domain_suffix(&h.hostname).map(|s| s == suf))
764                .unwrap_or(false);
765            let score = usage * 10 + u32::from(kw) * 5 + u32::from(same) * 3;
766            (score, h)
767        })
768        .collect()
769}
770
771/// Assemble the final picker list from pre-sorted `suggested` and `rest`
772/// slices. Inserts a `Suggestions` section label and a `Separator` only when
773/// both sides are non-empty.
774fn build_proxyjump_items(suggested: &[&HostEntry], rest: &[&HostEntry]) -> Vec<ProxyJumpCandidate> {
775    let mut items = Vec::with_capacity(suggested.len() + rest.len() + 2);
776    if !suggested.is_empty() {
777        items.push(ProxyJumpCandidate::SectionLabel("Suggestions"));
778    }
779    for h in suggested {
780        items.push(ProxyJumpCandidate::Host {
781            alias: h.alias.clone(),
782            hostname: h.hostname.clone(),
783            suggested: true,
784        });
785    }
786    if !suggested.is_empty() && !rest.is_empty() {
787        items.push(ProxyJumpCandidate::Separator);
788    }
789    for h in rest {
790        items.push(ProxyJumpCandidate::Host {
791            alias: h.alias.clone(),
792            hostname: h.hostname.clone(),
793            suggested: false,
794        });
795    }
796    items
797}
798
799/// Parse a ProxyJump directive value into its list of alias hops, stripping
800/// optional `user@` prefix and `:port` suffix (including IPv6 brackets).
801/// Malformed hops (empty, missing closing bracket on an IPv6 literal) are
802/// dropped rather than passed through as garbage that could never match a
803/// real alias.
804pub(crate) fn parse_proxy_jump_hops(proxy_jump: &str) -> Vec<String> {
805    proxy_jump
806        .split(',')
807        .filter_map(|hop| {
808            let h = hop.trim();
809            if h.is_empty() {
810                return None;
811            }
812            let h = h.split_once('@').map_or(h, |(_, host)| host);
813            let h = if let Some(bracketed) = h.strip_prefix('[') {
814                let (inner, _) = bracketed.split_once(']')?;
815                inner
816            } else {
817                h.rsplit_once(':').map_or(h, |(host, _)| host)
818            };
819            if h.is_empty() {
820                None
821            } else {
822                Some(h.to_string())
823            }
824        })
825        .collect()
826}
827
828/// True when the alias or hostname mentions a common jump-host keyword
829/// (`jump`, `bastion`, `gateway`, `proxy`, `gw`) as a substring.
830pub(crate) fn has_jump_keyword(alias: &str, hostname: &str) -> bool {
831    let a = alias.to_ascii_lowercase();
832    let h = hostname.to_ascii_lowercase();
833    JUMP_KEYWORDS
834        .iter()
835        .any(|kw| a.contains(kw) || h.contains(kw))
836}
837
838/// Extract the last two dot-separated labels of a hostname for domain
839/// matching. Returns None for single-label hostnames, IPv4 literals, and
840/// bracketed IPv6 literals where domain matching would be meaningless.
841/// Also rejects any string that parses as a valid `IpAddr` (which catches
842/// 4-octet IPv4 shapes without relying on a naive all-digits-per-label
843/// check that would miss mixed strings like `192.168.1.foo`).
844pub(crate) fn domain_suffix(hostname: &str) -> Option<String> {
845    let h = hostname.trim();
846    if h.is_empty() || h.starts_with('[') {
847        return None;
848    }
849    if h.parse::<std::net::IpAddr>().is_ok() {
850        return None;
851    }
852    let labels: Vec<&str> = h.split('.').collect();
853    if labels.len() < 2 {
854        return None;
855    }
856    // Trailing empty labels (e.g. `example.com.` FQDN) would silently
857    // produce a bogus `.com` suffix; normalise by trimming them off.
858    let mut end = labels.len();
859    while end > 0 && labels[end - 1].is_empty() {
860        end -= 1;
861    }
862    if end < 2 {
863        return None;
864    }
865    let tail = &labels[end - 2..end];
866    Some(tail.join(".").to_ascii_lowercase())
867}
868
869/// Step the ProxyJump picker selection one position in the requested
870/// direction, wrapping around and skipping any `Separator` entries. When
871/// nothing is currently selected, the first step lands on the first
872/// selectable host (forward) or the last selectable host (backward)
873/// instead of advancing past index 0.
874fn step_proxyjump_selection(app: &mut App, forward: bool) {
875    let candidates = app.proxyjump_candidates();
876    let len = candidates.len();
877    if len == 0 {
878        app.ui.proxyjump_picker.list.select(None);
879        return;
880    }
881    // When no prior selection exists, seed `next` so the first modular
882    // step lands on index 0 (forward) or len-1 (backward). Without this
883    // seed a fresh picker with selected() == None would skip index 0 on
884    // a Down press.
885    let seed: usize = match app.ui.proxyjump_picker.list.selected() {
886        Some(idx) => idx,
887        None if forward => len - 1,
888        None => 0,
889    };
890    let mut next = seed;
891    for _ in 0..len {
892        next = if forward {
893            (next + 1) % len
894        } else {
895            (next + len - 1) % len
896        };
897        if matches!(candidates.get(next), Some(ProxyJumpCandidate::Host { .. })) {
898            app.ui.proxyjump_picker.list.select(Some(next));
899            return;
900        }
901    }
902}