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 if let Some(home) = dirs::home_dir() {
243 let ssh_dir = home.join(".ssh");
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 pub fn select_prev_key(&mut self) {
256 super::cycle_selection(&mut self.keys.list_state, self.keys.list.len(), false);
257 }
258
259 pub fn select_next_key(&mut self) {
261 super::cycle_selection(&mut self.keys.list_state, self.keys.list.len(), true);
262 }
263
264 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 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 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 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 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 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 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 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 pub fn select_prev_proxyjump(&mut self) {
357 step_proxyjump_selection(self, false);
358 }
359
360 pub fn select_next_proxyjump(&mut self) {
362 step_proxyjump_selection(self, true);
363 }
364
365 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
828fn 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
846fn 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
869fn 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
897pub(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
926pub(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
936pub(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 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
967fn 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 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}