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