1use 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 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 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 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 pub fn selected_host_index(&self) -> Option<usize> {
54 if self.search.query.is_some() {
55 let sel = self.ui.list_state.selected()?;
57 self.search.filtered_indices.get(sel).copied()
58 } else {
59 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 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 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 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 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 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 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 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 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 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 paths = self.env().paths().cloned();
243 let ssh_dir = paths.as_ref().map(crate::runtime::env::Paths::ssh_dir);
244 if let Some(ssh_dir) = ssh_dir {
245 self.keys.list = ssh_keys::discover_keys(
246 paths.as_ref(),
247 Path::new(&ssh_dir),
248 &self.hosts_state.list,
249 );
250 if !self.keys.list.is_empty() && self.keys.list_state.selected().is_none() {
251 self.keys.list_state.select(Some(0));
252 }
253 self.reload.keys_dir_mtime = crate::app::reload_state::get_mtime(&ssh_dir);
254 self.reload.key_file_mtimes =
255 crate::app::reload_state::snapshot_key_mtimes(&ssh_dir, &self.keys.list);
256 }
257 }
258
259 pub fn select_prev_key(&mut self) {
261 super::cycle_selection(&mut self.keys.list_state, self.keys.list.len(), false);
262 }
263
264 pub fn select_next_key(&mut self) {
266 super::cycle_selection(&mut self.keys.list_state, self.keys.list.len(), true);
267 }
268
269 pub fn select_prev_picker_key(&mut self) {
271 super::cycle_selection(&mut self.ui.key_picker.list, self.keys.list.len(), false);
272 }
273
274 pub fn select_next_picker_key(&mut self) {
276 super::cycle_selection(&mut self.ui.key_picker.list, self.keys.list.len(), true);
277 }
278
279 pub fn select_prev_password_source(&mut self) {
281 super::cycle_selection(
282 &mut self.ui.password_picker.list,
283 crate::askpass::PASSWORD_SOURCES.len(),
284 false,
285 );
286 }
287
288 pub fn select_next_password_source(&mut self) {
290 super::cycle_selection(
291 &mut self.ui.password_picker.list,
292 crate::askpass::PASSWORD_SOURCES.len(),
293 true,
294 );
295 }
296
297 pub fn proxyjump_candidates(&self) -> Vec<ProxyJumpCandidate> {
307 let editing_alias = match &self.screen {
308 Screen::EditHost { alias, .. } => Some(alias.as_str()),
309 _ => None,
310 };
311 let editing_hostname = match &self.screen {
312 Screen::EditHost { alias, .. } => self
313 .hosts_state
314 .list
315 .iter()
316 .find(|h| h.alias == *alias)
317 .map(|h| h.hostname.as_str()),
318 _ => None,
319 };
320 let editing_suffix = editing_hostname.and_then(domain_suffix);
321
322 let usage_counts = proxyjump_usage_counts(&self.hosts_state.list, editing_alias);
323 let mut scored = score_proxyjump_candidates(
324 &self.hosts_state.list,
325 editing_alias,
326 editing_suffix.as_deref(),
327 &usage_counts,
328 );
329
330 scored.sort_by(|(sa, a), (sb, b)| sb.cmp(sa).then_with(|| a.alias.cmp(&b.alias)));
332 let suggested: Vec<&HostEntry> = scored
333 .iter()
334 .filter(|(s, _)| *s > 0)
335 .take(3)
336 .map(|(_, h)| *h)
337 .collect();
338 let suggested_aliases: std::collections::HashSet<&str> =
339 suggested.iter().map(|h| h.alias.as_str()).collect();
340
341 scored.sort_by(|(_, a), (_, b)| a.alias.cmp(&b.alias));
343 let rest: Vec<&HostEntry> = scored
344 .into_iter()
345 .map(|(_, h)| h)
346 .filter(|h| !suggested_aliases.contains(h.alias.as_str()))
347 .collect();
348
349 build_proxyjump_items(&suggested, &rest)
350 }
351
352 pub fn proxyjump_first_host_index(&self) -> Option<usize> {
355 self.proxyjump_candidates()
356 .iter()
357 .position(|c| matches!(c, ProxyJumpCandidate::Host { .. }))
358 }
359
360 pub fn select_prev_proxyjump(&mut self) {
362 step_proxyjump_selection(self, false);
363 }
364
365 pub fn select_next_proxyjump(&mut self) {
367 step_proxyjump_selection(self, true);
368 }
369
370 pub fn vault_role_candidates(&self) -> Vec<String> {
372 let mut seen = std::collections::HashSet::new();
373 let mut roles = Vec::new();
374 for host in &self.hosts_state.list {
375 if let Some(ref role) = host.vault_ssh {
376 if seen.insert(role.clone()) {
377 roles.push(role.clone());
378 }
379 }
380 }
381 for section in &self.providers.config.sections {
383 let role = section.vault_role.trim();
384 if !role.is_empty() && seen.insert(role.to_string()) {
385 roles.push(role.to_string());
386 }
387 }
388 roles.sort();
389 roles
390 }
391
392 pub fn select_prev_vault_role(&mut self) {
394 let len = self.vault_role_candidates().len();
395 super::cycle_selection(&mut self.ui.vault_role_picker.list, len, false);
396 }
397
398 pub fn select_next_vault_role(&mut self) {
400 let len = self.vault_role_candidates().len();
401 super::cycle_selection(&mut self.ui.vault_role_picker.list, len, true);
402 }
403
404 pub fn collect_unique_tags(&self) -> Vec<String> {
406 let mut seen = std::collections::HashSet::new();
407 let mut tags = Vec::new();
408 let mut has_stale = false;
409 let mut has_vault_ssh = false;
410 let mut has_vault_kv = false;
411 for host in &self.hosts_state.list {
412 for tag in host.provider_tags.iter().chain(host.tags.iter()) {
413 if seen.insert(tag.clone()) {
414 tags.push(tag.clone());
415 }
416 }
417 if let Some(ref provider) = host.provider {
418 if seen.insert(provider.clone()) {
419 tags.push(provider.clone());
420 }
421 }
422 if host.stale.is_some() {
423 has_stale = true;
424 }
425 if crate::vault_ssh::resolve_vault_role(
426 host.vault_ssh.as_deref(),
427 host.provider.as_deref(),
428 host.provider_label.as_deref(),
429 &self.providers.config,
430 )
431 .is_some()
432 {
433 has_vault_ssh = true;
434 }
435 if host
436 .askpass
437 .as_deref()
438 .map(|s| s.starts_with("vault:"))
439 .unwrap_or(false)
440 {
441 has_vault_kv = true;
442 }
443 }
444 for pattern in &self.hosts_state.patterns {
445 for tag in &pattern.tags {
446 if seen.insert(tag.clone()) {
447 tags.push(tag.clone());
448 }
449 }
450 }
451 if has_stale && seen.insert("stale".to_string()) {
452 tags.push("stale".to_string());
453 }
454 if !has_vault_ssh {
455 for section in &self.providers.config.sections {
456 if !section.vault_role.is_empty() {
457 has_vault_ssh = true;
458 break;
459 }
460 }
461 }
462 if has_vault_ssh && seen.insert("vault-ssh".to_string()) {
463 tags.push("vault-ssh".to_string());
464 }
465 if has_vault_kv && seen.insert("vault-kv".to_string()) {
466 tags.push("vault-kv".to_string());
467 }
468 tags.sort_by_cached_key(|a| a.to_lowercase());
469 tags
470 }
471
472 pub fn open_bulk_tag_editor(&mut self) -> bool {
481 let mut aliases: Vec<String> = Vec::new();
482 let mut skipped: Vec<String> = Vec::new();
483 let mut alias_set: std::collections::HashSet<String> = std::collections::HashSet::new();
484 for &idx in &self.hosts_state.multi_select {
485 if let Some(host) = self.hosts_state.list.get(idx) {
486 if !alias_set.insert(host.alias.clone()) {
487 continue;
488 }
489 if host.source_file.is_some() {
490 skipped.push(host.alias.clone());
491 }
492 aliases.push(host.alias.clone());
493 }
494 }
495 if aliases.is_empty() {
496 return false;
497 }
498 aliases.sort();
499 skipped.sort();
500
501 let mut candidate_tags: std::collections::BTreeSet<String> =
506 std::collections::BTreeSet::new();
507 for host in &self.hosts_state.list {
508 for tag in &host.tags {
509 candidate_tags.insert(tag.clone());
510 }
511 }
512 for pattern in &self.hosts_state.patterns {
513 for tag in &pattern.tags {
514 candidate_tags.insert(tag.clone());
515 }
516 }
517
518 let selected_set: std::collections::HashSet<&str> =
519 aliases.iter().map(|s| s.as_str()).collect();
520 let rows: Vec<BulkTagRow> = candidate_tags
521 .into_iter()
522 .map(|tag| {
523 let initial_count = self
524 .hosts_state
525 .list
526 .iter()
527 .filter(|h| selected_set.contains(h.alias.as_str()))
528 .filter(|h| h.tags.iter().any(|t| t == &tag))
529 .count();
530 BulkTagRow {
531 tag,
532 initial_count,
533 action: BulkTagAction::Leave,
534 }
535 })
536 .collect();
537
538 let initial_actions: Vec<BulkTagAction> = rows.iter().map(|r| r.action).collect();
542 self.forms.bulk_tag_editor = BulkTagEditorState {
543 rows,
544 aliases,
545 skipped_included: skipped,
546 new_tag_input: None,
547 new_tag_cursor: 0,
548 initial_actions,
549 };
550 self.ui.bulk_tag_editor_state = ListState::default();
551 if !self.forms.bulk_tag_editor.rows.is_empty() {
552 self.ui.bulk_tag_editor_state.select(Some(0));
553 }
554 self.set_screen(Screen::BulkTagEditor);
555 true
556 }
557
558 pub fn bulk_tag_editor_next(&mut self) {
560 super::cycle_selection(
561 &mut self.ui.bulk_tag_editor_state,
562 self.forms.bulk_tag_editor.rows.len(),
563 true,
564 );
565 }
566
567 pub fn bulk_tag_editor_prev(&mut self) {
569 super::cycle_selection(
570 &mut self.ui.bulk_tag_editor_state,
571 self.forms.bulk_tag_editor.rows.len(),
572 false,
573 );
574 }
575
576 pub fn bulk_tag_editor_cycle_current(&mut self) {
579 super::bulk_tag_cycle_current(&self.ui, &mut self.forms);
580 }
581
582 pub fn bulk_tag_editor_commit_new_tag(&mut self) {
587 super::bulk_tag_commit_new_tag(&mut self.ui, &mut self.forms);
588 }
589
590 pub fn bulk_tag_apply(&mut self) -> Result<BulkTagApplyResult, String> {
595 let result = super::apply_bulk_tags(&mut self.hosts_state, &mut self.forms)?;
600 if result.changed_hosts > 0 {
601 self.update_last_modified();
602 self.reload_hosts();
603 }
604 Ok(result)
605 }
606
607 pub fn open_tag_picker(&mut self) {
609 self.tags.list = self.collect_unique_tags();
610 self.ui.tag_picker_state = ListState::default();
611 if !self.tags.list.is_empty() {
612 self.ui.tag_picker_state.select(Some(0));
613 }
614 self.set_screen(Screen::TagPicker);
615 }
616
617 pub fn select_prev_tag(&mut self) {
619 super::cycle_selection(&mut self.ui.tag_picker_state, self.tags.list.len(), false);
620 }
621
622 pub fn select_next_tag(&mut self) {
624 super::cycle_selection(&mut self.ui.tag_picker_state, self.tags.list.len(), true);
625 }
626
627 pub fn refresh_tunnel_list(&mut self, alias: &str) {
630 self.tunnels
631 .load_directives(&self.hosts_state.ssh_config, alias);
632 }
633
634 pub fn select_prev_tunnel(&mut self) {
636 super::cycle_selection(
637 &mut self.ui.tunnel_list_state,
638 self.tunnels.list.len(),
639 false,
640 );
641 }
642
643 pub fn select_next_tunnel(&mut self) {
645 super::cycle_selection(
646 &mut self.ui.tunnel_list_state,
647 self.tunnels.list.len(),
648 true,
649 );
650 }
651
652 pub fn select_prev_snippet(&mut self) {
654 super::cycle_selection(
655 &mut self.ui.snippet_picker_state,
656 self.snippets.store.snippets.len(),
657 false,
658 );
659 }
660
661 pub fn select_next_snippet(&mut self) {
663 super::cycle_selection(
664 &mut self.ui.snippet_picker_state,
665 self.snippets.store.snippets.len(),
666 true,
667 );
668 }
669
670 pub fn select_next_skipping_headers(&mut self) {
673 let current = self.ui.list_state.selected().unwrap_or(0);
674 for i in (current + 1)..self.hosts_state.display_list.len() {
675 if !matches!(
676 self.hosts_state.display_list[i],
677 HostListItem::GroupHeader(_)
678 ) {
679 self.ui.list_state.select(Some(i));
680 return;
681 }
682 }
683 }
684
685 pub fn select_prev_skipping_headers(&mut self) {
687 let current = self.ui.list_state.selected().unwrap_or(0);
688 for i in (0..current).rev() {
689 if !matches!(
690 self.hosts_state.display_list[i],
691 HostListItem::GroupHeader(_)
692 ) {
693 self.ui.list_state.select(Some(i));
694 return;
695 }
696 }
697 }
698}
699
700const JUMP_KEYWORDS: &[&str] = &["jump", "bastion", "gateway", "proxy", "gw"];
701
702fn proxyjump_usage_counts(
705 hosts: &[HostEntry],
706 editing_alias: Option<&str>,
707) -> std::collections::HashMap<String, u32> {
708 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
709 for h in hosts {
710 if h.proxy_jump.is_empty() || editing_alias == Some(h.alias.as_str()) {
711 continue;
712 }
713 for hop in parse_proxy_jump_hops(&h.proxy_jump) {
714 *counts.entry(hop).or_insert(0) += 1;
715 }
716 }
717 counts
718}
719
720fn score_proxyjump_candidates<'a>(
723 hosts: &'a [HostEntry],
724 editing_alias: Option<&str>,
725 editing_suffix: Option<&str>,
726 usage_counts: &std::collections::HashMap<String, u32>,
727) -> Vec<(u32, &'a HostEntry)> {
728 hosts
729 .iter()
730 .filter(|h| editing_alias.is_none_or(|a| h.alias != a))
731 .map(|h| {
732 let usage = usage_counts.get(&h.alias).copied().unwrap_or(0);
733 let kw = has_jump_keyword(&h.alias, &h.hostname);
734 let same = editing_suffix
735 .and_then(|suf| domain_suffix(&h.hostname).map(|s| s == suf))
736 .unwrap_or(false);
737 let score = usage * 10 + u32::from(kw) * 5 + u32::from(same) * 3;
738 (score, h)
739 })
740 .collect()
741}
742
743fn build_proxyjump_items(suggested: &[&HostEntry], rest: &[&HostEntry]) -> Vec<ProxyJumpCandidate> {
747 let mut items = Vec::with_capacity(suggested.len() + rest.len() + 2);
748 if !suggested.is_empty() {
749 items.push(ProxyJumpCandidate::SectionLabel("Suggestions"));
750 }
751 for h in suggested {
752 items.push(ProxyJumpCandidate::Host {
753 alias: h.alias.clone(),
754 hostname: h.hostname.clone(),
755 suggested: true,
756 });
757 }
758 if !suggested.is_empty() && !rest.is_empty() {
759 items.push(ProxyJumpCandidate::Separator);
760 }
761 for h in rest {
762 items.push(ProxyJumpCandidate::Host {
763 alias: h.alias.clone(),
764 hostname: h.hostname.clone(),
765 suggested: false,
766 });
767 }
768 items
769}
770
771pub(crate) fn parse_proxy_jump_hops(proxy_jump: &str) -> Vec<String> {
777 proxy_jump
778 .split(',')
779 .filter_map(|hop| {
780 let h = hop.trim();
781 if h.is_empty() {
782 return None;
783 }
784 let h = h.split_once('@').map_or(h, |(_, host)| host);
785 let h = if let Some(bracketed) = h.strip_prefix('[') {
786 let (inner, _) = bracketed.split_once(']')?;
787 inner
788 } else {
789 h.rsplit_once(':').map_or(h, |(host, _)| host)
790 };
791 if h.is_empty() {
792 None
793 } else {
794 Some(h.to_string())
795 }
796 })
797 .collect()
798}
799
800pub(crate) fn has_jump_keyword(alias: &str, hostname: &str) -> bool {
803 let a = alias.to_ascii_lowercase();
804 let h = hostname.to_ascii_lowercase();
805 JUMP_KEYWORDS
806 .iter()
807 .any(|kw| a.contains(kw) || h.contains(kw))
808}
809
810pub(crate) fn domain_suffix(hostname: &str) -> Option<String> {
817 let h = hostname.trim();
818 if h.is_empty() || h.starts_with('[') {
819 return None;
820 }
821 if h.parse::<std::net::IpAddr>().is_ok() {
822 return None;
823 }
824 let labels: Vec<&str> = h.split('.').collect();
825 if labels.len() < 2 {
826 return None;
827 }
828 let mut end = labels.len();
831 while end > 0 && labels[end - 1].is_empty() {
832 end -= 1;
833 }
834 if end < 2 {
835 return None;
836 }
837 let tail = &labels[end - 2..end];
838 Some(tail.join(".").to_ascii_lowercase())
839}
840
841fn step_proxyjump_selection(app: &mut App, forward: bool) {
847 let candidates = app.proxyjump_candidates();
848 let len = candidates.len();
849 if len == 0 {
850 app.ui.proxyjump_picker.list.select(None);
851 return;
852 }
853 let seed: usize = match app.ui.proxyjump_picker.list.selected() {
858 Some(idx) => idx,
859 None if forward => len - 1,
860 None => 0,
861 };
862 let mut next = seed;
863 for _ in 0..len {
864 next = if forward {
865 (next + 1) % len
866 } else {
867 (next + len - 1) % len
868 };
869 if matches!(candidates.get(next), Some(ProxyJumpCandidate::Host { .. })) {
870 app.ui.proxyjump_picker.list.select(Some(next));
871 return;
872 }
873 }
874}