1use ratatui::widgets::ListState;
2
3use crate::history::ConnectionHistory;
4use crate::ssh_config::model::SshConfigFile;
5
6pub(super) fn contains_ci(haystack: &str, needle: &str) -> bool {
12 if needle.is_empty() {
13 return true;
14 }
15 if haystack.is_ascii() && needle.is_ascii() {
16 return haystack
17 .as_bytes()
18 .windows(needle.len())
19 .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()));
20 }
21 let needle_lower: Vec<char> = needle.chars().map(|c| c.to_ascii_lowercase()).collect();
23 let haystack_chars: Vec<char> = haystack.chars().collect();
24 haystack_chars.windows(needle_lower.len()).any(|window| {
25 window
26 .iter()
27 .zip(needle_lower.iter())
28 .all(|(h, n)| h.to_ascii_lowercase() == *n)
29 })
30}
31
32pub(super) fn eq_ci(a: &str, b: &str) -> bool {
34 a.eq_ignore_ascii_case(b)
35}
36
37mod baselines;
38mod container_state;
39mod containers_overview;
40mod display_list;
41mod file_browser_state;
42mod form_state;
43mod forms;
44mod groups;
45mod host_state;
46mod hosts;
47pub(crate) use hosts::migrate_renames_persistent_state;
48pub(crate) mod jump;
49mod key_push_state;
50mod keys_state;
51mod pickers;
52pub(crate) mod ping;
53mod provider_state;
54mod reload_state;
55mod screen;
56mod search;
57mod selection;
58mod snippet_state;
59mod status_state;
60mod tag_state;
61mod tunnel_state;
62mod ui_state;
63mod update;
64mod vault;
65
66pub use baselines::{FormBaseline, ProviderFormBaseline, SnippetFormBaseline, TunnelFormBaseline};
67pub use container_state::{ContainerSession, ContainerState};
68pub use containers_overview::{
69 ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest, ContainersOverviewState,
70 ContainersSortMode, InspectCacheEntry, LIST_CACHE_TTL_SECS, LOGS_TAIL, LogsCacheEntry,
71 REFRESH_MAX_PARALLEL, RefreshBatch, RefreshQueueItem,
72};
73pub use file_browser_state::FileBrowserState;
74pub use form_state::FormState;
75pub(crate) use forms::char_to_byte_pos;
76pub use forms::{
77 FormField, HostForm, ProviderFormField, ProviderFormFields, SnippetForm, SnippetFormField,
78 SnippetHostOutput, SnippetOutputState, SnippetParamFormState, TunnelForm, TunnelFormField,
79};
80pub use host_state::{
81 DeletedHost, GroupBy, HostListItem, HostState, ProxyJumpCandidate, SortMode, ViewMode,
82 health_summary_spans, health_summary_spans_for,
83};
84pub use key_push_state::KeyPushState;
85pub use keys_state::KeysState;
86pub use ping::{
87 PingState, PingStatus, classify_ping, ping_sort_key, propagate_ping_to_dependents, status_glyph,
88};
89pub use provider_state::{
90 LabelMigrationField, PendingLabelMigration, ProviderRow, ProviderState, SyncRecord,
91};
92pub use reload_state::{ConflictState, ReloadState};
93pub use screen::{ContainerLogsSearch, Screen, StackMember, TopPage, WhatsNewState};
94pub use search::SearchState;
95pub use snippet_state::SnippetState;
96pub use status_state::{MessageClass, StatusCenter, StatusMessage};
97pub use tag_state::{
98 BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, TagState,
99 select_display_tags,
100};
101pub use tunnel_state::{TunnelSortMode, TunnelState};
102pub use ui_state::UiSelection;
103pub use update::UpdateState;
104pub use vault::VaultState;
105
106impl Drop for App {
108 fn drop(&mut self) {
109 for (alias, mut tunnel) in self.tunnels.active.drain() {
110 if let Err(e) = tunnel.child.kill() {
111 log::debug!("[external] Failed to kill tunnel for {alias} on shutdown: {e}");
112 }
113 let _ = tunnel.child.wait();
114 }
115 if let Some(ref cancel) = self.vault.signing_cancel {
119 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
120 }
121 if let Some(handle) = self.vault.sign_thread.take() {
122 let _ = handle.join();
123 }
124 self.keys.push.shutdown();
128 }
129}
130
131pub struct App {
133 pub screen: Screen,
136 pub top_page: TopPage,
139 pub running: bool,
141 pub(crate) hosts_state: HostState,
143
144 pub status_center: StatusCenter,
147 pub(crate) ui: UiSelection,
149 pub search: SearchState,
151 pub reload: ReloadState,
153 pub conflict: ConflictState,
155
156 pub(crate) keys: KeysState,
158
159 pub tags: TagState,
161
162 pub(crate) forms: FormState,
164
165 pub history: ConnectionHistory,
167
168 pub(crate) providers: ProviderState,
170
171 pub(crate) ping: PingState,
173
174 pub vault: VaultState,
176
177 pub(crate) tunnels: TunnelState,
179
180 pub(crate) snippets: SnippetState,
182
183 pub update: UpdateState,
185
186 pub bw_session: Option<String>,
188
189 pub file_browser_state: FileBrowserState,
192 pub file_browser_session: Option<crate::file_browser::FileBrowserSession>,
194
195 pub(crate) container_state: ContainerState,
198 pub(crate) container_session: Option<ContainerSession>,
200 pub(crate) containers_overview: ContainersOverviewState,
202
203 pub demo_mode: bool,
205
206 pub jump: Option<JumpState>,
208}
209
210impl App {
211 pub fn new(config: SshConfigFile) -> Self {
212 let hosts = config.host_entries();
213 let patterns = config.pattern_entries();
214 let display_list = Self::build_display_list_from(&config, &hosts, &patterns);
215
216 let initial_selection = display_list.iter().position(|item| {
217 matches!(
218 item,
219 HostListItem::Host { .. } | HostListItem::Pattern { .. }
220 )
221 });
222
223 let reload = ReloadState::from_config(&config);
224 let hosts_state = HostState::from_config(config, hosts, patterns, display_list);
225
226 Self {
227 screen: Screen::HostList,
228 top_page: TopPage::default(),
229 running: true,
230 hosts_state,
231 status_center: StatusCenter::default(),
232 ui: UiSelection::new_with_initial_selection(initial_selection),
233 search: SearchState::default(),
234 reload,
235 conflict: ConflictState::default(),
236 keys: KeysState {
237 list: Vec::new(),
238 list_state: ratatui::widgets::ListState::default(),
239 activity: crate::key_activity::KeyActivityLog::load(),
240 push: KeyPushState::default(),
241 },
242 tags: TagState::default(),
243 forms: FormState::default(),
244 history: ConnectionHistory::load(),
245 providers: ProviderState::load(),
246 ping: PingState::from_preferences(),
247 vault: VaultState::default(),
248 tunnels: TunnelState::default(),
249 snippets: SnippetState::with_store_loaded(),
250 update: UpdateState::with_current_hint(),
251 bw_session: None,
252 file_browser_state: FileBrowserState::default(),
253 file_browser_session: None,
254 container_state: ContainerState {
255 cache: crate::containers::load_container_cache(),
256 ..ContainerState::default()
257 },
258 container_session: None,
259 containers_overview: ContainersOverviewState::default(),
260 demo_mode: false,
261 jump: None,
262 }
263 }
264
265 pub fn record_key_use(&mut self, alias: &str, now: u64) {
271 crate::key_activity::record_and_flush(&mut self.keys.activity, alias, now);
272 }
273
274 pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
278 self.hosts_state
279 .list
280 .iter()
281 .map(|h| h.alias.clone())
282 .collect()
283 }
284
285 pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
291 let new_aliases: Vec<String> = self
292 .hosts_state
293 .list
294 .iter()
295 .filter(|h| !before_aliases.contains(&h.alias))
296 .map(|h| h.alias.clone())
297 .collect();
298 for alias in new_aliases {
299 self.container_state.queue_fetch(alias);
300 }
301 }
302
303 pub fn reload_hosts(&mut self) {
305 let had_pending_vault_write = self.vault.pending_config_write;
306 let mut flushed_vault_write = false;
319 if self.vault.pending_config_write && !self.is_form_open() {
320 if self.external_config_changed() {
321 self.notify_error(
322 crate::messages::vault_config_skipped_external_change().to_string(),
323 );
324 log::warn!(
325 "[config] reload_hosts: skipping deferred vault write — external config changed"
326 );
327 } else {
328 match self.hosts_state.ssh_config.write() {
329 Ok(()) => flushed_vault_write = true,
330 Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
331 }
332 }
333 }
334 self.vault.pending_config_write = false;
337 log::debug!(
338 "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
339 );
340 let had_search = self.search.query.take();
341 let selected_alias = self
342 .selected_host()
343 .map(|h| h.alias.clone())
344 .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
345
346 self.tunnels.summaries_cache.clear();
347 self.hosts_state.render_cache.invalidate();
348 self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
349 self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
350 let valid_for_certs: std::collections::HashSet<&str> = self
353 .hosts_state
354 .list
355 .iter()
356 .map(|h| h.alias.as_str())
357 .collect();
358 self.vault
359 .cert_cache
360 .retain(|alias, _| valid_for_certs.contains(alias.as_str()));
361 self.vault
362 .cert_checks_in_flight
363 .retain(|alias| valid_for_certs.contains(alias.as_str()));
364 if self.hosts_state.sort_mode == SortMode::Original
365 && matches!(self.hosts_state.group_by, GroupBy::None)
366 {
367 self.hosts_state.display_list = Self::build_display_list_from(
368 &self.hosts_state.ssh_config,
369 &self.hosts_state.list,
370 &self.hosts_state.patterns,
371 );
372 } else {
373 self.apply_sort();
374 }
375
376 if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
378 self.set_screen(Screen::HostList);
379 self.forms.bulk_tag_editor = BulkTagEditorState::default();
380 }
381
382 self.hosts_state.multi_select.clear();
384
385 let valid_aliases: std::collections::HashSet<&str> = self
387 .hosts_state
388 .list
389 .iter()
390 .map(|h| h.alias.as_str())
391 .collect();
392
393 let pre_container_cache = self.container_state.cache.len();
400 self.container_state
401 .cache
402 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
403 let dropped_container_hosts =
404 pre_container_cache.saturating_sub(self.container_state.cache.len());
405 if dropped_container_hosts > 0 {
406 log::debug!(
407 "[purple] reload_hosts: dropped {} orphan container_cache host(s)",
408 dropped_container_hosts
409 );
410 crate::containers::save_container_cache(&self.container_state.cache);
411 }
412
413 let valid_container_ids: std::collections::HashSet<String> = self
417 .container_state
418 .cache
419 .values()
420 .flat_map(|e| e.containers.iter().map(|c| c.id.clone()))
421 .collect();
422 let pre_inspect = self.containers_overview.inspect_cache.entries.len();
423 self.containers_overview
424 .inspect_cache
425 .entries
426 .retain(|id, _| valid_container_ids.contains(id));
427 self.containers_overview
428 .inspect_cache
429 .in_flight
430 .retain(|id| valid_container_ids.contains(id));
431 self.containers_overview
434 .logs_cache
435 .entries
436 .retain(|id, _| valid_container_ids.contains(id));
437 self.containers_overview
438 .logs_cache
439 .in_flight
440 .retain(|id| valid_container_ids.contains(id));
441 self.containers_overview
448 .auto_list_in_flight
449 .retain(|alias| valid_aliases.contains(alias.as_str()));
450 if let Some(batch) = self.containers_overview.refresh_batch.as_mut() {
454 let pre = batch.in_flight_aliases.len();
455 batch
456 .in_flight_aliases
457 .retain(|alias| valid_aliases.contains(alias.as_str()));
458 let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
459 if dropped > 0 {
460 log::debug!(
461 "[purple] reload_hosts: dropped {} orphan refresh_batch in_flight alias(es)",
462 dropped
463 );
464 }
465 }
466 {
471 let mut sign = match self.vault.sign_in_flight.lock() {
472 Ok(g) => g,
473 Err(p) => p.into_inner(),
474 };
475 let pre = sign.len();
476 sign.retain(|alias| valid_aliases.contains(alias.as_str()));
477 let dropped = pre.saturating_sub(sign.len());
478 if dropped > 0 {
479 log::debug!(
480 "[purple] reload_hosts: dropped {} orphan sign_in_flight alias(es)",
481 dropped
482 );
483 }
484 }
485 let pre_paths = self.file_browser_state.host_paths.len();
488 self.file_browser_state
489 .host_paths
490 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
491 let dropped_paths = pre_paths.saturating_sub(self.file_browser_state.host_paths.len());
492 if dropped_paths > 0 {
493 log::debug!(
494 "[purple] reload_hosts: dropped {} orphan file_browser host_paths entrie(s)",
495 dropped_paths
496 );
497 }
498 let pre_demo = self.tunnels.demo_live_snapshots.len();
502 self.tunnels
503 .demo_live_snapshots
504 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
505 let dropped_demo = pre_demo.saturating_sub(self.tunnels.demo_live_snapshots.len());
506 if dropped_demo > 0 {
507 log::debug!(
508 "[purple] reload_hosts: dropped {} orphan demo_live_snapshots entrie(s)",
509 dropped_demo
510 );
511 }
512 let pre_collapsed = self.containers_overview.collapsed_hosts.len();
516 self.containers_overview
517 .collapsed_hosts
518 .retain(|alias| valid_aliases.contains(alias.as_str()));
519 let dropped_collapsed =
520 pre_collapsed.saturating_sub(self.containers_overview.collapsed_hosts.len());
521 if dropped_collapsed > 0 {
522 log::debug!(
523 "[purple] reload_hosts: dropped {} orphan collapsed_hosts entrie(s)",
524 dropped_collapsed
525 );
526 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
527 &self.containers_overview.collapsed_hosts,
528 ) {
529 log::warn!("[config] failed to save collapsed_hosts after prune: {e}");
530 }
531 }
532 let dropped_inspect =
533 pre_inspect.saturating_sub(self.containers_overview.inspect_cache.entries.len());
534 if dropped_inspect > 0 {
535 log::debug!(
536 "[purple] reload_hosts: dropped {} orphan inspect_cache entrie(s)",
537 dropped_inspect
538 );
539 }
540
541 let pre_status = self.ping.status.len();
542 let pre_checked = self.ping.last_checked.len();
543 self.ping
544 .status
545 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
546 self.ping
547 .last_checked
548 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
549 let dropped = pre_status.saturating_sub(self.ping.status.len())
550 + pre_checked.saturating_sub(self.ping.last_checked.len());
551 if dropped > 0 {
552 log::debug!(
553 "[purple] reload_hosts: pruned {} orphan ping entrie(s); {} aliases remain",
554 dropped,
555 valid_aliases.len()
556 );
557 }
558
559 if let Some(query) = had_search {
561 self.search.query = Some(query);
562 self.apply_filter();
563 } else {
564 self.search.query = None;
565 self.search.filtered_indices.clear();
566 self.search.filtered_pattern_indices.clear();
567 if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
569 self.ui.list_state.select(None);
570 } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
571 matches!(
572 item,
573 HostListItem::Host { .. } | HostListItem::Pattern { .. }
574 )
575 }) {
576 let current = self.ui.list_state.selected().unwrap_or(0);
577 if current >= self.hosts_state.display_list.len()
578 || !matches!(
579 self.hosts_state.display_list.get(current),
580 Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
581 )
582 {
583 self.ui.list_state.select(Some(pos));
584 }
585 } else {
586 self.ui.list_state.select(None);
587 }
588 }
589
590 if let Some(alias) = selected_alias {
592 self.select_host_by_alias(&alias);
593 }
594
595 log::debug!(
596 "[config] reload_hosts: hosts={} patterns={} display_items={}",
597 self.hosts_state.list.len(),
598 self.hosts_state.patterns.len(),
599 self.hosts_state.display_list.len(),
600 );
601 }
602
603 pub fn refresh_cert_cache(&mut self, alias: &str) {
614 if crate::demo_flag::is_demo() {
615 return;
616 }
617 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
618 self.vault.cert_cache.remove(alias);
619 return;
620 };
621 let role_some = crate::vault_ssh::resolve_vault_role(
622 host.vault_ssh.as_deref(),
623 host.provider.as_deref(),
624 host.provider_label.as_deref(),
625 &self.providers.config,
626 )
627 .is_some();
628 if !role_some {
629 self.vault.cert_cache.remove(alias);
630 return;
631 }
632 let cert_path = match crate::vault_ssh::resolve_cert_path(alias, &host.certificate_file) {
633 Ok(p) => p,
634 Err(_) => {
635 self.vault.cert_cache.remove(alias);
636 return;
637 }
638 };
639 let status = crate::vault_ssh::check_cert_validity(&cert_path);
640 let mtime = std::fs::metadata(&cert_path)
641 .ok()
642 .and_then(|m| m.modified().ok());
643 self.vault.cert_cache.insert(
644 alias.to_string(),
645 (std::time::Instant::now(), status, mtime),
646 );
647 }
648
649 #[cfg(test)]
656 pub fn sorted_provider_names(&self) -> Vec<String> {
657 self.providers.sorted_names()
658 }
659
660 pub fn is_form_open(&self) -> bool {
662 matches!(
663 self.screen,
664 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
665 )
666 }
667
668 pub fn open_jump(&mut self, mode: JumpMode) {
671 log::debug!("jump: open mode={:?}", mode);
672 let mut state = JumpState::for_mode(mode);
673 let recents_file = jump::load_recents();
674 state.recents = self.resolve_recents(&recents_file);
675 self.jump = Some(state);
676 self.recompute_jump_hits();
677 }
678
679 fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
682 let mode = self
683 .jump
684 .as_ref()
685 .map(|p| p.mode)
686 .unwrap_or(JumpMode::Hosts);
687 let mut out = Vec::with_capacity(file.entries.len());
688 for entry in &file.entries {
689 if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
690 out.push(hit);
691 }
692 }
693 out
694 }
695
696 #[cfg(test)]
700 pub(crate) fn resolve_recent_ref_for_test(
701 &self,
702 r: &RecentRef,
703 mode: JumpMode,
704 ) -> Option<JumpHit> {
705 self.resolve_recent_ref(r, mode)
706 }
707
708 fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
709 match r.kind {
710 SourceKind::Action => {
711 let key_char = r.key.chars().next()?;
712 let actions = JumpAction::for_mode(mode);
713 actions
714 .iter()
715 .find(|a| a.key == key_char)
716 .copied()
717 .map(JumpHit::Action)
718 }
719 SourceKind::Host => {
720 let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
721 Some(JumpHit::Host(HostHit {
722 alias: host.alias.clone(),
723 hostname: host.hostname.clone(),
724 tags: host.tags.clone(),
725 provider: host.provider.clone(),
726 user: host.user.clone(),
727 identity_file: host.identity_file.clone(),
728 proxy_jump: host.proxy_jump.clone(),
729 vault_ssh: host.vault_ssh.clone(),
730 }))
731 }
732 SourceKind::Tunnel => {
733 let (alias, port_str) = r.key.split_once(':')?;
734 let port: u16 = port_str.parse().ok()?;
735 let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
736 let rule = rules.iter().find(|r| r.bind_port == port)?;
737 Some(JumpHit::Tunnel(TunnelHit {
738 alias: alias.to_string(),
739 bind_port: rule.bind_port,
740 bind_port_str: rule.bind_port.to_string(),
741 destination: rule.display(),
742 active: self.tunnels.active.contains_key(alias),
743 }))
744 }
745 SourceKind::Container => {
746 let (alias, name) = r.key.split_once('/')?;
747 let entry = self.container_state.cache.get(alias)?;
748 let info = entry.containers.iter().find(|c| c.names == name)?;
749 Some(JumpHit::Container(ContainerHit {
750 alias: alias.to_string(),
751 container_name: info.names.clone(),
752 container_id: info.id.clone(),
753 state: info.state.clone(),
754 }))
755 }
756 SourceKind::Snippet => {
757 let snippet = self.snippets.store.get(&r.key)?;
758 Some(JumpHit::Snippet(SnippetHit {
759 name: snippet.name.clone(),
760 command_preview: preview(&snippet.command, 40),
761 }))
762 }
763 }
764 }
765
766 pub fn recompute_jump_hits(&mut self) {
772 let Some(mut state) = self.jump.take() else {
773 return;
774 };
775 let prior_identity = state
779 .visible_hits()
780 .get(state.selected)
781 .map(|h| h.identity());
782
783 let candidates = self.collect_jump_candidates(state.mode);
784 if state.query.is_empty() {
785 state.hits = candidates;
786 state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
787 self.jump = Some(state);
788 return;
789 }
790
791 let (scope, effective_query) = parse_query_scope(&state.query);
796
797 use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
798 use nucleo_matcher::{Config, Matcher, Utf32Str};
799 let matcher_state = state
800 .matcher
801 .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
802 let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
803 let mut buf: Vec<char> = Vec::new();
804 let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
805 for hit in candidates {
806 let mut best: u32 = 0;
807 let scoped_haystacks = scoped_haystacks_for(&hit, scope);
811 let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
812 hs
813 } else {
814 hit.haystacks()
815 };
816 for haystack in haystacks {
817 buf.clear();
818 let chars = Utf32Str::new(haystack, &mut buf);
819 if let Some(score) = pattern.score(chars, matcher_state) {
820 best = best.max(score);
821 }
822 }
823 if let JumpHit::Action(a) = &hit {
829 let single = effective_query.chars().next();
830 if effective_query.chars().count() == 1
831 && single
832 .map(|c| c.eq_ignore_ascii_case(&a.key))
833 .unwrap_or(false)
834 {
835 let mode_match = matches!(
836 (state.mode, a.target),
837 (JumpMode::Hosts, JumpActionTarget::Hosts)
838 | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
839 | (JumpMode::Containers, JumpActionTarget::Containers)
840 | (JumpMode::Keys, JumpActionTarget::Keys)
841 );
842 let bump = if mode_match { 20_000 } else { 10_000 };
843 best = best.saturating_add(bump);
844 }
845 }
846 let floor = match &hit {
850 JumpHit::Action(_) => jump::PALETTE_ACTION_FLOOR,
851 _ => 1,
852 };
853 if best >= floor {
854 scored.push((hit, best));
855 }
856 }
857 scored.sort_by(|a, b| {
860 b.1.cmp(&a.1)
861 .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
862 });
863 let mut per_kind: [usize; 5] = [0; 5];
866 let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
867 for (hit, _) in scored {
868 let slot = kind_rank(hit.kind()) as usize;
869 if per_kind[slot] < PALETTE_PER_SECTION_CAP {
870 per_kind[slot] += 1;
871 filtered.push(hit);
872 }
873 }
874 state.hits = filtered;
875 state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
876 self.jump = Some(state);
877 }
878
879 fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
880 let mut out: Vec<JumpHit> = Vec::new();
881 for h in &self.hosts_state.list {
883 out.push(JumpHit::Host(HostHit {
884 alias: h.alias.clone(),
885 hostname: h.hostname.clone(),
886 tags: h.tags.clone(),
887 provider: h.provider.clone(),
888 user: h.user.clone(),
889 identity_file: h.identity_file.clone(),
890 proxy_jump: h.proxy_jump.clone(),
891 vault_ssh: h.vault_ssh.clone(),
892 }));
893 }
894 for h in &self.hosts_state.list {
896 let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
897 for rule in rules {
898 out.push(JumpHit::Tunnel(TunnelHit {
899 alias: h.alias.clone(),
900 bind_port: rule.bind_port,
901 bind_port_str: rule.bind_port.to_string(),
902 destination: rule.display(),
903 active: self.tunnels.active.contains_key(&h.alias),
904 }));
905 }
906 }
907 for (alias, entry) in &self.container_state.cache {
910 for info in &entry.containers {
911 out.push(JumpHit::Container(ContainerHit {
912 alias: alias.clone(),
913 container_name: info.names.clone(),
914 container_id: info.id.clone(),
915 state: info.state.clone(),
916 }));
917 }
918 }
919 for snippet in &self.snippets.store.snippets {
921 out.push(JumpHit::Snippet(SnippetHit {
922 name: snippet.name.clone(),
923 command_preview: preview(&snippet.command, 40),
924 }));
925 }
926 for a in JumpAction::for_mode(mode) {
928 out.push(JumpHit::Action(*a));
929 }
930 out
931 }
932
933 pub fn record_jump_hit(&mut self, hit: &JumpHit) {
939 if self.demo_mode {
940 log::debug!("jump: record skipped (demo mode)");
941 return;
942 }
943 let mut file = jump::load_recents();
944 jump::touch_recent(&mut file, hit.identity());
945 if let Err(e) = jump::save_recents(&file) {
946 log::warn!("[purple] failed to save recents: {e}");
947 }
948 }
949
950 pub fn flush_pending_vault_write(&mut self) -> bool {
953 if !self.vault.pending_config_write || self.is_form_open() {
954 return false;
955 }
956 self.reload_hosts();
958 true
959 }
960
961 pub fn post_init(&mut self) {
965 let outcome = crate::onboarding::evaluate();
966 if let Some(text) = outcome.upgrade_toast {
967 self.enqueue_sticky_toast(text);
968 }
969 self.scan_keys();
973 }
974
975 fn enqueue_sticky_toast(&mut self, text: String) {
976 log::debug!("[purple] enqueue sticky toast: {}", text);
977 let msg = StatusMessage {
978 text,
979 class: MessageClass::Success,
980 tick_count: 0,
981 sticky: true,
982 created_at: std::time::Instant::now(),
983 };
984 self.status_center.toast = Some(msg);
985 }
986
987 pub fn notify(&mut self, text: impl Into<String>) {
989 self.status_center.set_status(text, false);
990 }
991
992 pub fn notify_error(&mut self, text: impl Into<String>) {
994 self.status_center.set_status(text, true);
995 }
996
997 pub fn notify_background(&mut self, text: impl Into<String>) {
999 self.status_center.set_background_status(text, false);
1000 }
1001
1002 pub fn notify_background_error(&mut self, text: impl Into<String>) {
1004 self.status_center.set_background_status(text, true);
1005 }
1006
1007 pub fn notify_warning(&mut self, text: impl Into<String>) {
1019 let msg = StatusMessage {
1020 text: text.into(),
1021 class: MessageClass::Warning,
1022 tick_count: 0,
1023 sticky: false,
1024 created_at: std::time::Instant::now(),
1025 };
1026 log::debug!("toast <- Warning: {}", msg.text);
1027 self.status_center.push_toast(msg);
1028 }
1029
1030 pub fn notify_progress(&mut self, text: impl Into<String>) {
1032 self.status_center.set_sticky_status(text, false);
1033 }
1034
1035 pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
1037 self.status_center.set_sticky_status(text, true);
1038 }
1039
1040 pub fn notify_info(&mut self, text: impl Into<String>) {
1042 self.status_center.set_info_status(text);
1043 }
1044
1045 pub fn tick_status(&mut self) {
1052 if !self.providers.syncing.is_empty() {
1054 return;
1055 }
1056 if let Some(ref status) = self.status_center.status {
1057 if status.sticky {
1058 return;
1059 }
1060 let timeout_ms = status.timeout_ms();
1061 if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
1062 {
1063 log::debug!("footer status expired: {}", status.text);
1064 self.status_center.status = None;
1065 }
1066 }
1067 }
1068
1069 pub fn tick_toast(&mut self) {
1071 self.status_center.tick_toast();
1072 }
1073
1074 pub fn check_config_changed(&mut self) {
1078 if matches!(
1079 self.screen,
1080 Screen::AddHost
1081 | Screen::EditHost { .. }
1082 | Screen::ProviderForm { .. }
1083 | Screen::TunnelList { .. }
1084 | Screen::TunnelForm { .. }
1085 | Screen::HostDetail { .. }
1086 | Screen::SnippetPicker { .. }
1087 | Screen::SnippetForm { .. }
1088 | Screen::SnippetOutput { .. }
1089 | Screen::SnippetParamForm { .. }
1090 | Screen::FileBrowser { .. }
1091 | Screen::Containers { .. }
1092 | Screen::ConfirmDelete { .. }
1093 | Screen::ConfirmHostKeyReset { .. }
1094 | Screen::ConfirmPurgeStale { .. }
1095 | Screen::ConfirmImport { .. }
1096 | Screen::ConfirmVaultSign { .. }
1097 | Screen::TagPicker
1098 | Screen::BulkTagEditor
1099 | Screen::ThemePicker
1100 | Screen::WhatsNew(_)
1101 ) || self.tags.input.is_some()
1102 {
1103 return;
1104 }
1105 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1106 let changed = current_mtime != self.reload.last_modified
1107 || self
1108 .reload
1109 .include_mtimes
1110 .iter()
1111 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1112 || self
1113 .reload
1114 .include_dir_mtimes
1115 .iter()
1116 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
1117 if changed {
1118 log::debug!(
1119 "[config] check_config_changed: mtime drift detected on {} -> reloading",
1120 self.reload.config_path.display()
1121 );
1122 if let Ok(new_config) = SshConfigFile::parse(&self.reload.config_path) {
1123 let before_aliases = self.snapshot_alias_set();
1124 self.hosts_state.ssh_config = new_config;
1125 self.hosts_state.undo_stack.clear();
1127 log::debug!(
1129 "[config] external config change: clearing {} ping result(s) + timestamps",
1130 self.ping.status.len()
1131 );
1132 self.ping.status.clear();
1133 self.ping.last_checked.clear();
1134 self.ping.filter_down_only = false;
1135 self.ping.checked_at = None;
1136 self.reload_hosts();
1137 self.reload.last_modified = current_mtime;
1138 self.reload.include_mtimes =
1139 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1140 self.reload.include_dir_mtimes =
1141 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1142 let count = self.hosts_state.list.len();
1143 self.notify_background(crate::messages::config_reloaded(count));
1144 self.queue_new_aliases_since(&before_aliases);
1145 }
1146 }
1147 }
1148
1149 pub fn check_keys_changed(&mut self) {
1159 if self.demo_mode {
1160 return;
1161 }
1162 if matches!(
1163 self.screen,
1164 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
1165 ) {
1166 return;
1167 }
1168 let Some(home) = dirs::home_dir() else {
1169 return;
1170 };
1171 let ssh_dir = home.join(".ssh");
1172 let current_dir_mtime = reload_state::get_mtime(&ssh_dir);
1173 let dir_changed = current_dir_mtime != self.reload.keys_dir_mtime;
1174 let files_changed = self
1175 .reload
1176 .key_file_mtimes
1177 .iter()
1178 .any(|(path, old)| reload_state::get_mtime(path) != *old);
1179 if !dir_changed && !files_changed {
1180 return;
1181 }
1182 log::debug!(
1183 "[purple] check_keys_changed: drift detected on {} (dir={} files={}) -> rescan",
1184 ssh_dir.display(),
1185 dir_changed,
1186 files_changed,
1187 );
1188 let previous = self.keys.list.len();
1189 self.scan_keys();
1190 let after = self.keys.list.len();
1191 if let Some(sel) = self.keys.list_state.selected() {
1194 if sel >= after {
1195 let next = after.checked_sub(1);
1196 self.keys.list_state.select(next);
1197 }
1198 } else if after > 0 {
1199 self.keys.list_state.select(Some(0));
1200 }
1201 if previous != after {
1202 log::debug!(
1203 "[purple] check_keys_changed: rescan {} -> {} keys",
1204 previous,
1205 after
1206 );
1207 }
1208 }
1209
1210 pub fn external_config_changed(&self) -> bool {
1219 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1220 current_mtime != self.reload.last_modified
1221 || self
1222 .reload
1223 .include_mtimes
1224 .iter()
1225 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1226 || self
1227 .reload
1228 .include_dir_mtimes
1229 .iter()
1230 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1231 }
1232
1233 pub fn update_last_modified(&mut self) {
1235 self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
1236 self.reload.include_mtimes =
1237 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1238 self.reload.include_dir_mtimes =
1239 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1240 }
1241
1242 pub fn has_any_vault_role(&self) -> bool {
1244 for host in &self.hosts_state.list {
1245 if host.vault_ssh.is_some() {
1246 return true;
1247 }
1248 }
1249 for section in &self.providers.config.sections {
1250 if !section.vault_role.is_empty() {
1251 return true;
1252 }
1253 }
1254 false
1255 }
1256
1257 pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
1259 self.tunnels.poll()
1260 }
1261
1262 pub fn refresh_tunnel_bind_ports(&mut self) {
1267 let mut ports: Vec<(String, u16, u32)> = Vec::new();
1268 for (alias, tunnel) in &self.tunnels.active {
1269 let pid = tunnel.child.id();
1270 for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
1271 ports.push((alias.clone(), rule.bind_port, pid));
1272 }
1273 }
1274 self.tunnels.set_lsof_ports(ports);
1275 }
1276}
1277
1278pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
1280 if len == 0 {
1281 return;
1282 }
1283 let i = match state.selected() {
1284 Some(i) => {
1285 if forward {
1286 if i >= len - 1 { 0 } else { i + 1 }
1287 } else if i == 0 {
1288 len - 1
1289 } else {
1290 i - 1
1291 }
1292 }
1293 None => 0,
1294 };
1295 state.select(Some(i));
1296}
1297
1298pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
1300 if len == 0 {
1301 return;
1302 }
1303 let current = state.selected().unwrap_or(0);
1304 let next = (current + page_size).min(len - 1);
1305 state.select(Some(next));
1306}
1307
1308pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
1310 if len == 0 {
1311 return;
1312 }
1313 let current = state.selected().unwrap_or(0);
1314 let prev = current.saturating_sub(page_size);
1315 state.select(Some(prev));
1316}
1317
1318pub use jump::{
1322 ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, JumpMode, JumpState, RecentRef,
1323 RecentsFile, SnippetHit, SourceKind, TunnelHit,
1324};
1325
1326#[cfg(test)]
1330pub type PaletteCommand = JumpAction;
1331
1332static ALL_JUMP_ACTIONS: &[JumpAction] = &[
1339 JumpAction {
1340 key: 'a',
1341 key_str: "a",
1342 label: "Hosts: Add host",
1343 aliases: &["new", "create"],
1344 target: JumpActionTarget::Hosts,
1345 },
1346 JumpAction {
1347 key: 'A',
1348 key_str: "A",
1349 label: "Hosts: Add pattern",
1350 aliases: &["new pattern", "wildcard"],
1351 target: JumpActionTarget::Hosts,
1352 },
1353 JumpAction {
1354 key: 'e',
1355 key_str: "e",
1356 label: "Hosts: Edit host",
1357 aliases: &["modify", "change"],
1358 target: JumpActionTarget::Hosts,
1359 },
1360 JumpAction {
1361 key: 'd',
1362 key_str: "d",
1363 label: "Hosts: Delete host",
1364 aliases: &["remove", "rm"],
1365 target: JumpActionTarget::Hosts,
1366 },
1367 JumpAction {
1368 key: 'c',
1369 key_str: "c",
1370 label: "Hosts: Clone host",
1371 aliases: &["duplicate", "copy"],
1372 target: JumpActionTarget::Hosts,
1373 },
1374 JumpAction {
1375 key: 'u',
1376 key_str: "u",
1377 label: "Hosts: Undo delete",
1378 aliases: &["restore"],
1379 target: JumpActionTarget::Hosts,
1380 },
1381 JumpAction {
1382 key: 't',
1383 key_str: "t",
1384 label: "Hosts: Tag host",
1385 aliases: &["label", "category"],
1386 target: JumpActionTarget::Hosts,
1387 },
1388 JumpAction {
1389 key: 'i',
1390 key_str: "i",
1391 label: "Hosts: Show all directives",
1392 aliases: &["raw", "config", "settings"],
1393 target: JumpActionTarget::Hosts,
1394 },
1395 JumpAction {
1396 key: 'y',
1397 key_str: "y",
1398 label: "Clipboard: Copy SSH command",
1399 aliases: &["yank"],
1400 target: JumpActionTarget::Hosts,
1401 },
1402 JumpAction {
1403 key: 'x',
1404 key_str: "x",
1405 label: "Clipboard: Copy config block",
1406 aliases: &["yank config"],
1407 target: JumpActionTarget::Hosts,
1408 },
1409 JumpAction {
1410 key: 'X',
1411 key_str: "X",
1412 label: "Hosts: Purge stale hosts",
1413 aliases: &["clean", "cleanup"],
1414 target: JumpActionTarget::Hosts,
1415 },
1416 JumpAction {
1417 key: 'F',
1418 key_str: "F",
1419 label: "Files: Browse remote files",
1420 aliases: &[
1421 "browse",
1422 "filesystem",
1423 "scp",
1424 "sftp",
1425 "transfer",
1426 "explorer",
1427 "open",
1428 ],
1429 target: JumpActionTarget::Hosts,
1430 },
1431 JumpAction {
1432 key: 'C',
1433 key_str: "C",
1434 label: "Containers: List containers",
1435 aliases: &["docker", "podman", "ps", "open"],
1436 target: JumpActionTarget::Hosts,
1437 },
1438 JumpAction {
1439 key: 'K',
1440 key_str: "K",
1441 label: "Keys: Manage SSH keys",
1442 aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
1443 target: JumpActionTarget::Hosts,
1444 },
1445 JumpAction {
1446 key: 'S',
1447 key_str: "S",
1448 label: "Providers: Manage cloud sync",
1449 aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
1450 target: JumpActionTarget::Hosts,
1451 },
1452 JumpAction {
1453 key: 'V',
1454 key_str: "V",
1455 label: "Vault: Sign certificate",
1456 aliases: &["hashicorp", "ssh cert", "vault ssh"],
1457 target: JumpActionTarget::Hosts,
1458 },
1459 JumpAction {
1460 key: 'I',
1461 key_str: "I",
1462 label: "Hosts: Import from known_hosts",
1463 aliases: &["known", "import"],
1464 target: JumpActionTarget::Hosts,
1465 },
1466 JumpAction {
1467 key: 'm',
1468 key_str: "m",
1469 label: "Settings: Switch theme",
1470 aliases: &["color", "appearance", "dark", "light"],
1471 target: JumpActionTarget::Hosts,
1472 },
1473 JumpAction {
1474 key: 'n',
1475 key_str: "n",
1476 label: "Help: What's new",
1477 aliases: &["changelog", "news", "release notes"],
1478 target: JumpActionTarget::Hosts,
1479 },
1480 JumpAction {
1481 key: 'r',
1482 key_str: "r",
1483 label: "Snippets: Run snippet",
1484 aliases: &["execute", "command"],
1485 target: JumpActionTarget::Hosts,
1486 },
1487 JumpAction {
1488 key: 'R',
1489 key_str: "R",
1490 label: "Snippets: Run on all visible",
1491 aliases: &["batch", "execute all"],
1492 target: JumpActionTarget::Hosts,
1493 },
1494 JumpAction {
1495 key: 'p',
1496 key_str: "p",
1497 label: "Hosts: Ping host",
1498 aliases: &["health", "check"],
1499 target: JumpActionTarget::Hosts,
1500 },
1501 JumpAction {
1502 key: 'P',
1503 key_str: "P",
1504 label: "Hosts: Ping all hosts",
1505 aliases: &["health all"],
1506 target: JumpActionTarget::Hosts,
1507 },
1508 JumpAction {
1509 key: '!',
1510 key_str: "!",
1511 label: "Hosts: Show down only",
1512 aliases: &["filter offline", "down only"],
1513 target: JumpActionTarget::Hosts,
1514 },
1515 JumpAction {
1519 key: 'T',
1520 key_str: "T",
1521 label: "Tunnels: Manage tunnels",
1522 aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
1523 target: JumpActionTarget::Hosts,
1524 },
1525 JumpAction {
1526 key: 'a',
1527 key_str: "a",
1528 label: "Tunnels: Add tunnel",
1529 aliases: &["new tunnel", "create tunnel", "forward"],
1530 target: JumpActionTarget::Tunnels,
1531 },
1532 JumpAction {
1533 key: 'e',
1534 key_str: "e",
1535 label: "Tunnels: Edit tunnel",
1536 aliases: &["modify tunnel"],
1537 target: JumpActionTarget::Tunnels,
1538 },
1539 JumpAction {
1540 key: 'd',
1541 key_str: "d",
1542 label: "Tunnels: Delete tunnel",
1543 aliases: &["remove tunnel"],
1544 target: JumpActionTarget::Tunnels,
1545 },
1546 JumpAction {
1547 key: 's',
1548 key_str: "s",
1549 label: "Tunnels: Sort",
1550 aliases: &["order tunnels"],
1551 target: JumpActionTarget::Tunnels,
1552 },
1553 JumpAction {
1554 key: 'R',
1555 key_str: "R",
1556 label: "Containers: Refresh all hosts",
1557 aliases: &["reload containers", "fetch", "rescan"],
1558 target: JumpActionTarget::Containers,
1559 },
1560 JumpAction {
1561 key: 's',
1562 key_str: "s",
1563 label: "Containers: Cycle sort",
1564 aliases: &["order containers", "sort by host", "sort by name"],
1565 target: JumpActionTarget::Containers,
1566 },
1567 JumpAction {
1568 key: 'v',
1569 key_str: "v",
1570 label: "Containers: Toggle detail panel",
1571 aliases: &["show details", "hide details", "compact view"],
1572 target: JumpActionTarget::Containers,
1573 },
1574 JumpAction {
1578 key: 'c',
1579 key_str: "c",
1580 label: "Keys: Copy public key",
1581 aliases: &["yank", "clipboard", "pubkey"],
1582 target: JumpActionTarget::Keys,
1583 },
1584 JumpAction {
1585 key: 'p',
1586 key_str: "p",
1587 label: "Keys: Push to host",
1588 aliases: &["install", "ssh-copy-id", "deploy", "upload"],
1589 target: JumpActionTarget::Keys,
1590 },
1591 JumpAction {
1592 key: 'V',
1593 key_str: "V",
1594 label: "Keys: Sign Vault SSH certificate",
1595 aliases: &["vault", "renew cert", "sign"],
1596 target: JumpActionTarget::Keys,
1597 },
1598];
1599
1600pub const PALETTE_PER_SECTION_CAP: usize = 32;
1605
1606pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
1609 if let Some((prefix, rest)) = query.split_once(':') {
1610 let scope = match prefix.trim() {
1611 "user" => Some(QueryScope::User),
1612 "host" => Some(QueryScope::Hostname),
1613 "proxy" => Some(QueryScope::ProxyJump),
1614 "vault" => Some(QueryScope::VaultSsh),
1615 "tag" => Some(QueryScope::Tag),
1616 _ => None,
1617 };
1618 if scope.is_some() {
1619 return (scope, rest.trim_start());
1620 }
1621 }
1622 (None, query)
1623}
1624
1625#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1626pub enum QueryScope {
1627 User,
1628 Hostname,
1629 ProxyJump,
1630 VaultSsh,
1631 Tag,
1632}
1633
1634fn preview(s: &str, max: usize) -> String {
1636 let s = s.replace('\n', " ");
1637 let chars: Vec<char> = s.chars().collect();
1638 if chars.len() <= max {
1639 s
1640 } else {
1641 let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
1642 out.push_str("...");
1643 out
1644 }
1645}
1646
1647fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
1652 let scope = scope?;
1653 match (hit, scope) {
1654 (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
1655 (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
1656 Some(vec![&h.hostname])
1657 }
1658 (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
1659 Some(vec![&h.proxy_jump])
1660 }
1661 (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
1662 (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
1663 _ => None,
1665 }
1666}
1667
1668pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
1673 if query.is_empty() {
1674 return None;
1675 }
1676 let q = query.to_lowercase();
1677 let alias_hit = host.alias.to_lowercase().contains(&q);
1678 let hostname_hit = host.hostname.to_lowercase().contains(&q);
1679 if alias_hit || hostname_hit {
1680 return None;
1681 }
1682 if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
1683 return Some(MatchSource::User);
1684 }
1685 if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
1686 return Some(MatchSource::ProxyJump);
1687 }
1688 if let Some(role) = &host.vault_ssh {
1689 if role.to_lowercase().contains(&q) {
1690 return Some(MatchSource::VaultSsh);
1691 }
1692 }
1693 if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
1694 return Some(MatchSource::IdentityFile);
1695 }
1696 None
1697}
1698
1699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1700pub enum MatchSource {
1701 User,
1702 ProxyJump,
1703 VaultSsh,
1704 IdentityFile,
1705}
1706
1707fn kind_rank(k: SourceKind) -> u8 {
1708 match k {
1709 SourceKind::Host => 0,
1710 SourceKind::Tunnel => 1,
1711 SourceKind::Container => 2,
1712 SourceKind::Snippet => 3,
1713 SourceKind::Action => 4,
1714 }
1715}
1716
1717fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
1722 if let Some(target) = prior {
1723 if let Some(idx) = hits.iter().position(|h| &h.identity() == target) {
1724 return idx;
1725 }
1726 }
1727 fallback.min(hits.len().saturating_sub(1))
1728}
1729
1730impl JumpAction {
1731 #[cfg(test)]
1732 pub fn all() -> &'static [JumpAction] {
1733 ALL_JUMP_ACTIONS
1734 }
1735
1736 pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
1740 ALL_JUMP_ACTIONS
1741 }
1742}
1743
1744#[cfg(test)]
1745mod tests;