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 push_help_overlay(&mut self) {
35 if matches!(self.screen, Screen::Help { .. }) {
36 return;
37 }
38 log::debug!("screen: {} → Help", self.screen.variant_name());
39 let old = std::mem::replace(&mut self.screen, Screen::HostList);
40 self.screen = Screen::Help {
41 return_screen: Box::new(old),
42 };
43 }
44
45 pub fn pop_help_overlay(&mut self) {
49 let returned = {
50 let Screen::Help { return_screen } = &mut self.screen else {
51 return;
52 };
53 std::mem::replace(&mut **return_screen, Screen::HostList)
54 };
55 log::debug!("screen: Help → {}", returned.variant_name());
56 self.screen = returned;
57 }
58
59 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 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 pub fn selected_host_index(&self) -> Option<usize> {
82 if self.search.query.is_some() {
83 let sel = self.ui.list_state.selected()?;
85 self.search.filtered_indices.get(sel).copied()
86 } else {
87 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 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 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 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 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 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 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 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 pub fn page_down_host(&mut self) {
207 self.ui.detail_scroll = 0;
208 const PAGE_SIZE: usize = 10;
209 if self.search.query.is_some() {
210 super::page_down(
211 &mut self.ui.list_state,
212 self.search.filtered_indices.len(),
213 PAGE_SIZE,
214 );
215 } else {
216 let current = self.ui.list_state.selected().unwrap_or(0);
217 let mut target = current;
218 let mut items_skipped = 0;
219 let len = self.hosts_state.display_list.len();
220 for i in (current + 1)..len {
221 if matches!(
222 self.hosts_state.display_list[i],
223 HostListItem::Host { .. } | HostListItem::Pattern { .. }
224 ) {
225 target = i;
226 items_skipped += 1;
227 if items_skipped >= PAGE_SIZE {
228 break;
229 }
230 }
231 }
232 if target != current {
233 self.ui.list_state.select(Some(target));
234 }
235 }
236 }
237
238 pub fn page_up_host(&mut self) {
240 self.ui.detail_scroll = 0;
241 const PAGE_SIZE: usize = 10;
242 if self.search.query.is_some() {
243 super::page_up(
244 &mut self.ui.list_state,
245 self.search.filtered_indices.len(),
246 PAGE_SIZE,
247 );
248 } else {
249 let current = self.ui.list_state.selected().unwrap_or(0);
250 let mut target = current;
251 let mut items_skipped = 0;
252 for i in (0..current).rev() {
253 if matches!(
254 self.hosts_state.display_list[i],
255 HostListItem::Host { .. } | HostListItem::Pattern { .. }
256 ) {
257 target = i;
258 items_skipped += 1;
259 if items_skipped >= PAGE_SIZE {
260 break;
261 }
262 }
263 }
264 if target != current {
265 self.ui.list_state.select(Some(target));
266 }
267 }
268 }
269 pub fn scan_keys(&mut self) {
270 let paths = self.env().paths().cloned();
271 let ssh_dir = paths.as_ref().map(crate::runtime::env::Paths::ssh_dir);
272 if let Some(ssh_dir) = ssh_dir {
273 self.keys.list = ssh_keys::discover_keys(
274 paths.as_ref(),
275 Path::new(&ssh_dir),
276 &self.hosts_state.list,
277 );
278 if !self.keys.list.is_empty() && self.keys.list_state.selected().is_none() {
279 self.keys.list_state.select(Some(0));
280 }
281 self.reload.keys_dir_mtime = crate::app::reload_state::get_mtime(&ssh_dir);
282 self.reload.key_file_mtimes =
283 crate::app::reload_state::snapshot_key_mtimes(&ssh_dir, &self.keys.list);
284 }
285 }
286
287 pub fn select_prev_key(&mut self) {
289 super::cycle_selection(&mut self.keys.list_state, self.keys.list.len(), false);
290 }
291
292 pub fn select_next_key(&mut self) {
294 super::cycle_selection(&mut self.keys.list_state, self.keys.list.len(), true);
295 }
296
297 pub fn select_prev_picker_key(&mut self) {
299 super::cycle_selection(&mut self.ui.key_picker.list, self.keys.list.len(), false);
300 }
301
302 pub fn select_next_picker_key(&mut self) {
304 super::cycle_selection(&mut self.ui.key_picker.list, self.keys.list.len(), true);
305 }
306
307 pub fn select_prev_password_source(&mut self) {
309 super::cycle_selection(
310 &mut self.ui.password_picker.list,
311 crate::askpass::PASSWORD_SOURCES.len(),
312 false,
313 );
314 }
315
316 pub fn select_next_password_source(&mut self) {
318 super::cycle_selection(
319 &mut self.ui.password_picker.list,
320 crate::askpass::PASSWORD_SOURCES.len(),
321 true,
322 );
323 }
324
325 pub fn proxyjump_candidates(&self) -> Vec<ProxyJumpCandidate> {
335 let editing_alias = match &self.screen {
336 Screen::EditHost { alias, .. } => Some(alias.as_str()),
337 _ => None,
338 };
339 let editing_hostname = match &self.screen {
340 Screen::EditHost { alias, .. } => self
341 .hosts_state
342 .list
343 .iter()
344 .find(|h| h.alias == *alias)
345 .map(|h| h.hostname.as_str()),
346 _ => None,
347 };
348 let editing_suffix = editing_hostname.and_then(domain_suffix);
349
350 let usage_counts = proxyjump_usage_counts(&self.hosts_state.list, editing_alias);
351 let mut scored = score_proxyjump_candidates(
352 &self.hosts_state.list,
353 editing_alias,
354 editing_suffix.as_deref(),
355 &usage_counts,
356 );
357
358 scored.sort_by(|(sa, a), (sb, b)| sb.cmp(sa).then_with(|| a.alias.cmp(&b.alias)));
360 let suggested: Vec<&HostEntry> = scored
361 .iter()
362 .filter(|(s, _)| *s > 0)
363 .take(3)
364 .map(|(_, h)| *h)
365 .collect();
366 let suggested_aliases: std::collections::HashSet<&str> =
367 suggested.iter().map(|h| h.alias.as_str()).collect();
368
369 scored.sort_by(|(_, a), (_, b)| a.alias.cmp(&b.alias));
371 let rest: Vec<&HostEntry> = scored
372 .into_iter()
373 .map(|(_, h)| h)
374 .filter(|h| !suggested_aliases.contains(h.alias.as_str()))
375 .collect();
376
377 build_proxyjump_items(&suggested, &rest)
378 }
379
380 pub fn proxyjump_first_host_index(&self) -> Option<usize> {
383 self.proxyjump_candidates()
384 .iter()
385 .position(|c| matches!(c, ProxyJumpCandidate::Host { .. }))
386 }
387
388 pub fn select_prev_proxyjump(&mut self) {
390 step_proxyjump_selection(self, false);
391 }
392
393 pub fn select_next_proxyjump(&mut self) {
395 step_proxyjump_selection(self, true);
396 }
397
398 pub fn vault_role_candidates(&self) -> Vec<String> {
400 let mut seen = std::collections::HashSet::new();
401 let mut roles = Vec::new();
402 for host in &self.hosts_state.list {
403 if let Some(ref role) = host.vault_ssh {
404 if seen.insert(role.clone()) {
405 roles.push(role.clone());
406 }
407 }
408 }
409 for section in &self.providers.config.sections {
411 let role = section.vault_role.trim();
412 if !role.is_empty() && seen.insert(role.to_string()) {
413 roles.push(role.to_string());
414 }
415 }
416 roles.sort();
417 roles
418 }
419
420 pub fn select_prev_vault_role(&mut self) {
422 let len = self.vault_role_candidates().len();
423 super::cycle_selection(&mut self.ui.vault_role_picker.list, len, false);
424 }
425
426 pub fn select_next_vault_role(&mut self) {
428 let len = self.vault_role_candidates().len();
429 super::cycle_selection(&mut self.ui.vault_role_picker.list, len, true);
430 }
431
432 pub fn collect_unique_tags(&self) -> Vec<String> {
434 let mut seen = std::collections::HashSet::new();
435 let mut tags = Vec::new();
436 let mut has_stale = false;
437 let mut has_vault_ssh = false;
438 let mut has_vault_kv = false;
439 for host in &self.hosts_state.list {
440 for tag in host.provider_tags.iter().chain(host.tags.iter()) {
441 if seen.insert(tag.clone()) {
442 tags.push(tag.clone());
443 }
444 }
445 if let Some(ref provider) = host.provider {
446 if seen.insert(provider.clone()) {
447 tags.push(provider.clone());
448 }
449 }
450 if host.stale.is_some() {
451 has_stale = true;
452 }
453 if crate::vault_ssh::resolve_vault_role(
454 host.vault_ssh.as_deref(),
455 host.provider.as_deref(),
456 host.provider_label.as_deref(),
457 &self.providers.config,
458 )
459 .is_some()
460 {
461 has_vault_ssh = true;
462 }
463 if host
464 .askpass
465 .as_deref()
466 .map(|s| s.starts_with("vault:"))
467 .unwrap_or(false)
468 {
469 has_vault_kv = true;
470 }
471 }
472 for pattern in &self.hosts_state.patterns {
473 for tag in &pattern.tags {
474 if seen.insert(tag.clone()) {
475 tags.push(tag.clone());
476 }
477 }
478 }
479 if has_stale && seen.insert("stale".to_string()) {
480 tags.push("stale".to_string());
481 }
482 if !has_vault_ssh {
483 for section in &self.providers.config.sections {
484 if !section.vault_role.is_empty() {
485 has_vault_ssh = true;
486 break;
487 }
488 }
489 }
490 if has_vault_ssh && seen.insert("vault-ssh".to_string()) {
491 tags.push("vault-ssh".to_string());
492 }
493 if has_vault_kv && seen.insert("vault-kv".to_string()) {
494 tags.push("vault-kv".to_string());
495 }
496 tags.sort_by_cached_key(|a| a.to_lowercase());
497 tags
498 }
499
500 pub fn open_bulk_tag_editor(&mut self) -> bool {
509 let mut aliases: Vec<String> = Vec::new();
510 let mut skipped: Vec<String> = Vec::new();
511 let mut alias_set: std::collections::HashSet<String> = std::collections::HashSet::new();
512 for &idx in &self.hosts_state.multi_select {
513 if let Some(host) = self.hosts_state.list.get(idx) {
514 if !alias_set.insert(host.alias.clone()) {
515 continue;
516 }
517 if host.source_file.is_some() {
518 skipped.push(host.alias.clone());
519 }
520 aliases.push(host.alias.clone());
521 }
522 }
523 if aliases.is_empty() {
524 return false;
525 }
526 aliases.sort();
527 skipped.sort();
528
529 let mut candidate_tags: std::collections::BTreeSet<String> =
534 std::collections::BTreeSet::new();
535 for host in &self.hosts_state.list {
536 for tag in &host.tags {
537 candidate_tags.insert(tag.clone());
538 }
539 }
540 for pattern in &self.hosts_state.patterns {
541 for tag in &pattern.tags {
542 candidate_tags.insert(tag.clone());
543 }
544 }
545
546 let selected_set: std::collections::HashSet<&str> =
547 aliases.iter().map(|s| s.as_str()).collect();
548 let rows: Vec<BulkTagRow> = candidate_tags
549 .into_iter()
550 .map(|tag| {
551 let initial_count = self
552 .hosts_state
553 .list
554 .iter()
555 .filter(|h| selected_set.contains(h.alias.as_str()))
556 .filter(|h| h.tags.iter().any(|t| t == &tag))
557 .count();
558 BulkTagRow {
559 tag,
560 initial_count,
561 action: BulkTagAction::Leave,
562 }
563 })
564 .collect();
565
566 let initial_actions: Vec<BulkTagAction> = rows.iter().map(|r| r.action).collect();
570 self.forms.bulk_tag_editor = BulkTagEditorState {
571 rows,
572 aliases,
573 skipped_included: skipped,
574 new_tag_input: None,
575 new_tag_cursor: 0,
576 initial_actions,
577 };
578 self.ui.bulk_tag_editor_state = ListState::default();
579 if !self.forms.bulk_tag_editor.rows.is_empty() {
580 self.ui.bulk_tag_editor_state.select(Some(0));
581 }
582 self.set_screen(Screen::BulkTagEditor);
583 true
584 }
585
586 pub fn bulk_tag_editor_next(&mut self) {
588 super::cycle_selection(
589 &mut self.ui.bulk_tag_editor_state,
590 self.forms.bulk_tag_editor.rows.len(),
591 true,
592 );
593 }
594
595 pub fn bulk_tag_editor_prev(&mut self) {
597 super::cycle_selection(
598 &mut self.ui.bulk_tag_editor_state,
599 self.forms.bulk_tag_editor.rows.len(),
600 false,
601 );
602 }
603
604 pub fn bulk_tag_editor_cycle_current(&mut self) {
607 super::bulk_tag_cycle_current(&self.ui, &mut self.forms);
608 }
609
610 pub fn bulk_tag_editor_commit_new_tag(&mut self) {
615 super::bulk_tag_commit_new_tag(&mut self.ui, &mut self.forms);
616 }
617
618 pub fn bulk_tag_apply(&mut self) -> Result<BulkTagApplyResult, String> {
623 let result = super::apply_bulk_tags(&mut self.hosts_state, &mut self.forms)?;
628 if result.changed_hosts > 0 {
629 self.update_last_modified();
630 self.reload_hosts();
631 }
632 Ok(result)
633 }
634
635 pub fn open_tag_picker(&mut self) {
637 self.tags.list = self.collect_unique_tags();
638 self.ui.tag_picker_state = ListState::default();
639 if !self.tags.list.is_empty() {
640 self.ui.tag_picker_state.select(Some(0));
641 }
642 self.set_screen(Screen::TagPicker);
643 }
644
645 pub fn select_prev_tag(&mut self) {
647 super::cycle_selection(&mut self.ui.tag_picker_state, self.tags.list.len(), false);
648 }
649
650 pub fn select_next_tag(&mut self) {
652 super::cycle_selection(&mut self.ui.tag_picker_state, self.tags.list.len(), true);
653 }
654
655 pub fn refresh_tunnel_list(&mut self, alias: &str) {
658 self.tunnels
659 .load_directives(&self.hosts_state.ssh_config, alias);
660 }
661
662 pub fn select_prev_tunnel(&mut self) {
664 super::cycle_selection(
665 &mut self.ui.tunnel_list_state,
666 self.tunnels.list.len(),
667 false,
668 );
669 }
670
671 pub fn select_next_tunnel(&mut self) {
673 super::cycle_selection(
674 &mut self.ui.tunnel_list_state,
675 self.tunnels.list.len(),
676 true,
677 );
678 }
679
680 pub fn select_prev_snippet(&mut self) {
682 super::cycle_selection(
683 &mut self.ui.snippet_picker_state,
684 self.snippets.store.snippets.len(),
685 false,
686 );
687 }
688
689 pub fn select_next_snippet(&mut self) {
691 super::cycle_selection(
692 &mut self.ui.snippet_picker_state,
693 self.snippets.store.snippets.len(),
694 true,
695 );
696 }
697
698 pub fn select_next_skipping_headers(&mut self) {
701 let current = self.ui.list_state.selected().unwrap_or(0);
702 for i in (current + 1)..self.hosts_state.display_list.len() {
703 if !matches!(
704 self.hosts_state.display_list[i],
705 HostListItem::GroupHeader(_)
706 ) {
707 self.ui.list_state.select(Some(i));
708 return;
709 }
710 }
711 }
712
713 pub fn select_prev_skipping_headers(&mut self) {
715 let current = self.ui.list_state.selected().unwrap_or(0);
716 for i in (0..current).rev() {
717 if !matches!(
718 self.hosts_state.display_list[i],
719 HostListItem::GroupHeader(_)
720 ) {
721 self.ui.list_state.select(Some(i));
722 return;
723 }
724 }
725 }
726}
727
728const JUMP_KEYWORDS: &[&str] = &["jump", "bastion", "gateway", "proxy", "gw"];
729
730fn proxyjump_usage_counts(
733 hosts: &[HostEntry],
734 editing_alias: Option<&str>,
735) -> std::collections::HashMap<String, u32> {
736 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
737 for h in hosts {
738 if h.proxy_jump.is_empty() || editing_alias == Some(h.alias.as_str()) {
739 continue;
740 }
741 for hop in parse_proxy_jump_hops(&h.proxy_jump) {
742 *counts.entry(hop).or_insert(0) += 1;
743 }
744 }
745 counts
746}
747
748fn score_proxyjump_candidates<'a>(
751 hosts: &'a [HostEntry],
752 editing_alias: Option<&str>,
753 editing_suffix: Option<&str>,
754 usage_counts: &std::collections::HashMap<String, u32>,
755) -> Vec<(u32, &'a HostEntry)> {
756 hosts
757 .iter()
758 .filter(|h| editing_alias.is_none_or(|a| h.alias != a))
759 .map(|h| {
760 let usage = usage_counts.get(&h.alias).copied().unwrap_or(0);
761 let kw = has_jump_keyword(&h.alias, &h.hostname);
762 let same = editing_suffix
763 .and_then(|suf| domain_suffix(&h.hostname).map(|s| s == suf))
764 .unwrap_or(false);
765 let score = usage * 10 + u32::from(kw) * 5 + u32::from(same) * 3;
766 (score, h)
767 })
768 .collect()
769}
770
771fn build_proxyjump_items(suggested: &[&HostEntry], rest: &[&HostEntry]) -> Vec<ProxyJumpCandidate> {
775 let mut items = Vec::with_capacity(suggested.len() + rest.len() + 2);
776 if !suggested.is_empty() {
777 items.push(ProxyJumpCandidate::SectionLabel("Suggestions"));
778 }
779 for h in suggested {
780 items.push(ProxyJumpCandidate::Host {
781 alias: h.alias.clone(),
782 hostname: h.hostname.clone(),
783 suggested: true,
784 });
785 }
786 if !suggested.is_empty() && !rest.is_empty() {
787 items.push(ProxyJumpCandidate::Separator);
788 }
789 for h in rest {
790 items.push(ProxyJumpCandidate::Host {
791 alias: h.alias.clone(),
792 hostname: h.hostname.clone(),
793 suggested: false,
794 });
795 }
796 items
797}
798
799pub(crate) fn parse_proxy_jump_hops(proxy_jump: &str) -> Vec<String> {
805 proxy_jump
806 .split(',')
807 .filter_map(|hop| {
808 let h = hop.trim();
809 if h.is_empty() {
810 return None;
811 }
812 let h = h.split_once('@').map_or(h, |(_, host)| host);
813 let h = if let Some(bracketed) = h.strip_prefix('[') {
814 let (inner, _) = bracketed.split_once(']')?;
815 inner
816 } else {
817 h.rsplit_once(':').map_or(h, |(host, _)| host)
818 };
819 if h.is_empty() {
820 None
821 } else {
822 Some(h.to_string())
823 }
824 })
825 .collect()
826}
827
828pub(crate) fn has_jump_keyword(alias: &str, hostname: &str) -> bool {
831 let a = alias.to_ascii_lowercase();
832 let h = hostname.to_ascii_lowercase();
833 JUMP_KEYWORDS
834 .iter()
835 .any(|kw| a.contains(kw) || h.contains(kw))
836}
837
838pub(crate) fn domain_suffix(hostname: &str) -> Option<String> {
845 let h = hostname.trim();
846 if h.is_empty() || h.starts_with('[') {
847 return None;
848 }
849 if h.parse::<std::net::IpAddr>().is_ok() {
850 return None;
851 }
852 let labels: Vec<&str> = h.split('.').collect();
853 if labels.len() < 2 {
854 return None;
855 }
856 let mut end = labels.len();
859 while end > 0 && labels[end - 1].is_empty() {
860 end -= 1;
861 }
862 if end < 2 {
863 return None;
864 }
865 let tail = &labels[end - 2..end];
866 Some(tail.join(".").to_ascii_lowercase())
867}
868
869fn step_proxyjump_selection(app: &mut App, forward: bool) {
875 let candidates = app.proxyjump_candidates();
876 let len = candidates.len();
877 if len == 0 {
878 app.ui.proxyjump_picker.list.select(None);
879 return;
880 }
881 let seed: usize = match app.ui.proxyjump_picker.list.selected() {
886 Some(idx) => idx,
887 None if forward => len - 1,
888 None => 0,
889 };
890 let mut next = seed;
891 for _ in 0..len {
892 next = if forward {
893 (next + 1) % len
894 } else {
895 (next + len - 1) % len
896 };
897 if matches!(candidates.get(next), Some(ProxyJumpCandidate::Host { .. })) {
898 app.ui.proxyjump_picker.list.select(Some(next));
899 return;
900 }
901 }
902}