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