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(handle) = self.vault.cancel_signing_run() {
119 let _ = handle.join();
120 }
121 self.keys.push.shutdown();
125 }
126}
127
128pub struct App {
130 pub screen: Screen,
133 pub top_page: TopPage,
136 pub running: bool,
138 pub(crate) hosts_state: HostState,
140
141 pub(crate) status_center: StatusCenter,
144 pub(crate) ui: UiSelection,
146 pub(crate) search: SearchState,
148 pub(crate) reload: ReloadState,
150 pub(crate) conflict: ConflictState,
152
153 pub(crate) keys: KeysState,
155
156 pub(crate) tags: TagState,
158
159 pub(crate) forms: FormState,
161
162 pub(crate) history: ConnectionHistory,
164
165 pub(crate) providers: ProviderState,
167
168 pub(crate) ping: PingState,
170
171 pub(crate) vault: VaultState,
173
174 pub(crate) tunnels: TunnelState,
176
177 pub(crate) snippets: SnippetState,
179
180 pub(crate) update: UpdateState,
182
183 pub bw_session: Option<String>,
185
186 pub(crate) file_browser_state: FileBrowserState,
189 pub(crate) file_browser_session: Option<crate::file_browser::FileBrowserSession>,
191
192 pub(crate) container_state: ContainerState,
195 pub(crate) container_session: Option<ContainerSession>,
197 pub(crate) containers_overview: ContainersOverviewState,
199
200 pub demo_mode: bool,
202
203 pub(crate) jump: Option<JumpState>,
205}
206
207impl App {
208 pub fn new(config: SshConfigFile) -> Self {
209 let hosts = config.host_entries();
210 let patterns = config.pattern_entries();
211 let display_list = Self::build_display_list_from(&config, &hosts, &patterns);
212
213 let initial_selection = display_list.iter().position(|item| {
214 matches!(
215 item,
216 HostListItem::Host { .. } | HostListItem::Pattern { .. }
217 )
218 });
219
220 let reload = ReloadState::from_config(&config);
221 let hosts_state = HostState::from_config(config, hosts, patterns, display_list);
222
223 Self {
224 screen: Screen::HostList,
225 top_page: TopPage::default(),
226 running: true,
227 hosts_state,
228 status_center: StatusCenter::default(),
229 ui: UiSelection::new_with_initial_selection(initial_selection),
230 search: SearchState::default(),
231 reload,
232 conflict: ConflictState::default(),
233 keys: KeysState {
234 list: Vec::new(),
235 list_state: ratatui::widgets::ListState::default(),
236 activity: crate::key_activity::KeyActivityLog::load(),
237 push: KeyPushState::default(),
238 },
239 tags: TagState::default(),
240 forms: FormState::default(),
241 history: ConnectionHistory::load(),
242 providers: ProviderState::load(),
243 ping: PingState::from_preferences(),
244 vault: VaultState::default(),
245 tunnels: TunnelState::default(),
246 snippets: SnippetState::with_store_loaded(),
247 update: UpdateState::with_current_hint(),
248 bw_session: None,
249 file_browser_state: FileBrowserState::default(),
250 file_browser_session: None,
251 container_state: ContainerState {
252 cache: crate::containers::load_container_cache(),
253 ..ContainerState::default()
254 },
255 container_session: None,
256 containers_overview: ContainersOverviewState::default(),
257 demo_mode: false,
258 jump: None,
259 }
260 }
261
262 pub fn record_key_use(&mut self, alias: &str, now: u64) {
268 crate::key_activity::record_and_flush(&mut self.keys.activity, alias, now);
269 }
270
271 pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
275 self.hosts_state
276 .list
277 .iter()
278 .map(|h| h.alias.clone())
279 .collect()
280 }
281
282 pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
288 let new_aliases: Vec<String> = self
289 .hosts_state
290 .list
291 .iter()
292 .filter(|h| !before_aliases.contains(&h.alias))
293 .map(|h| h.alias.clone())
294 .collect();
295 for alias in new_aliases {
296 self.container_state.queue_fetch(alias);
297 }
298 }
299
300 pub fn reload_hosts(&mut self) {
302 let had_pending_vault_write = self.vault.pending_config_write;
303 let mut flushed_vault_write = false;
316 if self.vault.pending_config_write && !self.is_form_open() {
317 if self.external_config_changed() {
318 self.notify_error(
319 crate::messages::vault_config_skipped_external_change().to_string(),
320 );
321 log::warn!(
322 "[config] reload_hosts: skipping deferred vault write — external config changed"
323 );
324 } else {
325 match self.hosts_state.ssh_config.write() {
326 Ok(()) => flushed_vault_write = true,
327 Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
328 }
329 }
330 }
331 self.vault.pending_config_write = false;
334 log::debug!(
335 "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
336 );
337 let had_search = self.search.query.take();
338 let selected_alias = self
339 .selected_host()
340 .map(|h| h.alias.clone())
341 .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
342
343 self.tunnels.summaries_cache.clear();
344 self.hosts_state.render_cache.invalidate();
345 self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
346 self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
347 let valid_for_certs: std::collections::HashSet<&str> = self
350 .hosts_state
351 .list
352 .iter()
353 .map(|h| h.alias.as_str())
354 .collect();
355 self.vault
356 .cert_cache
357 .retain(|alias, _| valid_for_certs.contains(alias.as_str()));
358 self.vault
359 .cert_checks_in_flight
360 .retain(|alias| valid_for_certs.contains(alias.as_str()));
361 if self.hosts_state.sort_mode == SortMode::Original
362 && matches!(self.hosts_state.group_by, GroupBy::None)
363 {
364 self.hosts_state.display_list = Self::build_display_list_from(
365 &self.hosts_state.ssh_config,
366 &self.hosts_state.list,
367 &self.hosts_state.patterns,
368 );
369 } else {
370 self.apply_sort();
371 }
372
373 if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
375 self.set_screen(Screen::HostList);
376 self.forms.bulk_tag_editor = BulkTagEditorState::default();
377 }
378
379 self.hosts_state.multi_select.clear();
381
382 let valid_aliases: std::collections::HashSet<&str> = self
384 .hosts_state
385 .list
386 .iter()
387 .map(|h| h.alias.as_str())
388 .collect();
389
390 let pre_container_cache = self.container_state.cache.len();
397 self.container_state
398 .cache
399 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
400 let dropped_container_hosts =
401 pre_container_cache.saturating_sub(self.container_state.cache.len());
402 if dropped_container_hosts > 0 {
403 log::debug!(
404 "[purple] reload_hosts: dropped {} orphan container_cache host(s)",
405 dropped_container_hosts
406 );
407 crate::containers::save_container_cache(&self.container_state.cache);
408 }
409
410 let valid_container_ids: std::collections::HashSet<String> = self
414 .container_state
415 .cache
416 .values()
417 .flat_map(|e| e.containers.iter().map(|c| c.id.clone()))
418 .collect();
419 let pre_inspect = self.containers_overview.inspect_cache.entries.len();
420 self.containers_overview
421 .inspect_cache
422 .entries
423 .retain(|id, _| valid_container_ids.contains(id));
424 self.containers_overview
425 .inspect_cache
426 .in_flight
427 .retain(|id| valid_container_ids.contains(id));
428 self.containers_overview
431 .logs_cache
432 .entries
433 .retain(|id, _| valid_container_ids.contains(id));
434 self.containers_overview
435 .logs_cache
436 .in_flight
437 .retain(|id| valid_container_ids.contains(id));
438 self.containers_overview
445 .auto_list_in_flight
446 .retain(|alias| valid_aliases.contains(alias.as_str()));
447 if let Some(batch) = self.containers_overview.refresh_batch.as_mut() {
451 let pre = batch.in_flight_aliases.len();
452 batch
453 .in_flight_aliases
454 .retain(|alias| valid_aliases.contains(alias.as_str()));
455 let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
456 if dropped > 0 {
457 log::debug!(
458 "[purple] reload_hosts: dropped {} orphan refresh_batch in_flight alias(es)",
459 dropped
460 );
461 }
462 }
463 {
468 let mut sign = match self.vault.sign_in_flight.lock() {
469 Ok(g) => g,
470 Err(p) => p.into_inner(),
471 };
472 let pre = sign.len();
473 sign.retain(|alias| valid_aliases.contains(alias.as_str()));
474 let dropped = pre.saturating_sub(sign.len());
475 if dropped > 0 {
476 log::debug!(
477 "[purple] reload_hosts: dropped {} orphan sign_in_flight alias(es)",
478 dropped
479 );
480 }
481 }
482 let pre_paths = self.file_browser_state.host_paths.len();
485 self.file_browser_state
486 .host_paths
487 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
488 let dropped_paths = pre_paths.saturating_sub(self.file_browser_state.host_paths.len());
489 if dropped_paths > 0 {
490 log::debug!(
491 "[purple] reload_hosts: dropped {} orphan file_browser host_paths entrie(s)",
492 dropped_paths
493 );
494 }
495 let pre_demo = self.tunnels.demo_live_snapshots.len();
499 self.tunnels
500 .demo_live_snapshots
501 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
502 let dropped_demo = pre_demo.saturating_sub(self.tunnels.demo_live_snapshots.len());
503 if dropped_demo > 0 {
504 log::debug!(
505 "[purple] reload_hosts: dropped {} orphan demo_live_snapshots entrie(s)",
506 dropped_demo
507 );
508 }
509 let pre_collapsed = self.containers_overview.collapsed_hosts.len();
513 self.containers_overview
514 .collapsed_hosts
515 .retain(|alias| valid_aliases.contains(alias.as_str()));
516 let dropped_collapsed =
517 pre_collapsed.saturating_sub(self.containers_overview.collapsed_hosts.len());
518 if dropped_collapsed > 0 {
519 log::debug!(
520 "[purple] reload_hosts: dropped {} orphan collapsed_hosts entrie(s)",
521 dropped_collapsed
522 );
523 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
524 &self.containers_overview.collapsed_hosts,
525 ) {
526 log::warn!("[config] failed to save collapsed_hosts after prune: {e}");
527 }
528 }
529 let dropped_inspect =
530 pre_inspect.saturating_sub(self.containers_overview.inspect_cache.entries.len());
531 if dropped_inspect > 0 {
532 log::debug!(
533 "[purple] reload_hosts: dropped {} orphan inspect_cache entrie(s)",
534 dropped_inspect
535 );
536 }
537
538 let pre_status = self.ping.status.len();
539 let pre_checked = self.ping.last_checked.len();
540 self.ping
541 .status
542 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
543 self.ping
544 .last_checked
545 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
546 let dropped = pre_status.saturating_sub(self.ping.status.len())
547 + pre_checked.saturating_sub(self.ping.last_checked.len());
548 if dropped > 0 {
549 log::debug!(
550 "[purple] reload_hosts: pruned {} orphan ping entrie(s); {} aliases remain",
551 dropped,
552 valid_aliases.len()
553 );
554 }
555
556 if let Some(query) = had_search {
558 self.search.query = Some(query);
559 self.apply_filter();
560 } else {
561 self.search.query = None;
562 self.search.filtered_indices.clear();
563 self.search.filtered_pattern_indices.clear();
564 if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
566 self.ui.list_state.select(None);
567 } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
568 matches!(
569 item,
570 HostListItem::Host { .. } | HostListItem::Pattern { .. }
571 )
572 }) {
573 let current = self.ui.list_state.selected().unwrap_or(0);
574 if current >= self.hosts_state.display_list.len()
575 || !matches!(
576 self.hosts_state.display_list.get(current),
577 Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
578 )
579 {
580 self.ui.list_state.select(Some(pos));
581 }
582 } else {
583 self.ui.list_state.select(None);
584 }
585 }
586
587 if let Some(alias) = selected_alias {
589 self.select_host_by_alias(&alias);
590 }
591
592 log::debug!(
593 "[config] reload_hosts: hosts={} patterns={} display_items={}",
594 self.hosts_state.list.len(),
595 self.hosts_state.patterns.len(),
596 self.hosts_state.display_list.len(),
597 );
598 }
599
600 pub fn refresh_cert_cache(&mut self, alias: &str) {
611 if crate::demo_flag::is_demo() {
612 return;
613 }
614 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
615 self.vault.cert_cache.remove(alias);
616 return;
617 };
618 let role_some = crate::vault_ssh::resolve_vault_role(
619 host.vault_ssh.as_deref(),
620 host.provider.as_deref(),
621 host.provider_label.as_deref(),
622 &self.providers.config,
623 )
624 .is_some();
625 if !role_some {
626 self.vault.cert_cache.remove(alias);
627 return;
628 }
629 let cert_path = match crate::vault_ssh::resolve_cert_path(alias, &host.certificate_file) {
630 Ok(p) => p,
631 Err(_) => {
632 self.vault.cert_cache.remove(alias);
633 return;
634 }
635 };
636 let status = crate::vault_ssh::check_cert_validity(&cert_path);
637 let mtime = std::fs::metadata(&cert_path)
638 .ok()
639 .and_then(|m| m.modified().ok());
640 self.vault.cert_cache.insert(
641 alias.to_string(),
642 (std::time::Instant::now(), status, mtime),
643 );
644 }
645
646 #[cfg(test)]
653 pub fn sorted_provider_names(&self) -> Vec<String> {
654 self.providers.sorted_names()
655 }
656
657 pub fn is_form_open(&self) -> bool {
659 matches!(
660 self.screen,
661 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
662 )
663 }
664
665 pub fn open_jump(&mut self, mode: JumpMode) {
668 log::debug!("jump: open mode={:?}", mode);
669 let mut state = JumpState::for_mode(mode);
670 let recents_file = jump::load_recents();
671 state.recents = self.resolve_recents(&recents_file);
672 self.jump = Some(state);
673 self.recompute_jump_hits();
674 }
675
676 pub(crate) fn close_jump(&mut self) {
680 self.jump = None;
681 }
682
683 fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
686 let mode = self
687 .jump
688 .as_ref()
689 .map(|p| p.mode)
690 .unwrap_or(JumpMode::Hosts);
691 let mut out = Vec::with_capacity(file.entries.len());
692 for entry in &file.entries {
693 if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
694 out.push(hit);
695 }
696 }
697 out
698 }
699
700 #[cfg(test)]
704 pub(crate) fn resolve_recent_ref_for_test(
705 &self,
706 r: &RecentRef,
707 mode: JumpMode,
708 ) -> Option<JumpHit> {
709 self.resolve_recent_ref(r, mode)
710 }
711
712 fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
713 match r.kind {
714 SourceKind::Action => {
715 let key_char = r.key.chars().next()?;
716 let actions = JumpAction::for_mode(mode);
717 actions
718 .iter()
719 .find(|a| a.key == key_char)
720 .copied()
721 .map(JumpHit::Action)
722 }
723 SourceKind::Host => {
724 let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
725 Some(JumpHit::Host(HostHit {
726 alias: host.alias.clone(),
727 hostname: host.hostname.clone(),
728 tags: host.tags.clone(),
729 provider: host.provider.clone(),
730 user: host.user.clone(),
731 identity_file: host.identity_file.clone(),
732 proxy_jump: host.proxy_jump.clone(),
733 vault_ssh: host.vault_ssh.clone(),
734 }))
735 }
736 SourceKind::Tunnel => {
737 let (alias, port_str) = r.key.split_once(':')?;
738 let port: u16 = port_str.parse().ok()?;
739 let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
740 let rule = rules.iter().find(|r| r.bind_port == port)?;
741 Some(JumpHit::Tunnel(TunnelHit {
742 alias: alias.to_string(),
743 bind_port: rule.bind_port,
744 bind_port_str: rule.bind_port.to_string(),
745 destination: rule.display(),
746 active: self.tunnels.active.contains_key(alias),
747 }))
748 }
749 SourceKind::Container => {
750 let (alias, name) = r.key.split_once('/')?;
751 let entry = self.container_state.cache.get(alias)?;
752 let info = entry.containers.iter().find(|c| c.names == name)?;
753 Some(JumpHit::Container(ContainerHit {
754 alias: alias.to_string(),
755 container_name: info.names.clone(),
756 container_id: info.id.clone(),
757 state: info.state.clone(),
758 }))
759 }
760 SourceKind::Snippet => {
761 let snippet = self.snippets.store.get(&r.key)?;
762 Some(JumpHit::Snippet(SnippetHit {
763 name: snippet.name.clone(),
764 command_preview: preview(&snippet.command, 40),
765 }))
766 }
767 }
768 }
769
770 pub fn recompute_jump_hits(&mut self) {
776 let Some(mut state) = self.jump.take() else {
777 return;
778 };
779 let prior_identity = state
783 .visible_hits()
784 .get(state.selected)
785 .map(|h| h.identity());
786
787 let candidates = self.collect_jump_candidates(state.mode);
788 if state.query.is_empty() {
789 state.hits = candidates;
790 state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
791 self.jump = Some(state);
792 return;
793 }
794
795 let (scope, effective_query) = parse_query_scope(&state.query);
800
801 use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
802 use nucleo_matcher::{Config, Matcher, Utf32Str};
803 let matcher_state = state
804 .matcher
805 .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
806 let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
807 let mut buf: Vec<char> = Vec::new();
808 let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
809 for hit in candidates {
810 let mut best: u32 = 0;
811 let scoped_haystacks = scoped_haystacks_for(&hit, scope);
815 let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
816 hs
817 } else {
818 hit.haystacks()
819 };
820 for haystack in haystacks {
821 buf.clear();
822 let chars = Utf32Str::new(haystack, &mut buf);
823 if let Some(score) = pattern.score(chars, matcher_state) {
824 best = best.max(score);
825 }
826 }
827 if let JumpHit::Action(a) = &hit {
833 let single = effective_query.chars().next();
834 if effective_query.chars().count() == 1
835 && single
836 .map(|c| c.eq_ignore_ascii_case(&a.key))
837 .unwrap_or(false)
838 {
839 let mode_match = matches!(
840 (state.mode, a.target),
841 (JumpMode::Hosts, JumpActionTarget::Hosts)
842 | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
843 | (JumpMode::Containers, JumpActionTarget::Containers)
844 | (JumpMode::Keys, JumpActionTarget::Keys)
845 );
846 let bump = if mode_match { 20_000 } else { 10_000 };
847 best = best.saturating_add(bump);
848 }
849 }
850 let floor = match &hit {
854 JumpHit::Action(_) => jump::PALETTE_ACTION_FLOOR,
855 _ => 1,
856 };
857 if best >= floor {
858 scored.push((hit, best));
859 }
860 }
861 scored.sort_by(|a, b| {
864 b.1.cmp(&a.1)
865 .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
866 });
867 let mut per_kind: [usize; 5] = [0; 5];
870 let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
871 for (hit, _) in scored {
872 let slot = kind_rank(hit.kind()) as usize;
873 if per_kind[slot] < PALETTE_PER_SECTION_CAP {
874 per_kind[slot] += 1;
875 filtered.push(hit);
876 }
877 }
878 state.hits = filtered;
879 let display = state.visible_hits();
888 let top_display = state
889 .hits
890 .first()
891 .map(|h| h.kind())
892 .and_then(|k| display.iter().position(|h| h.kind() == k))
893 .unwrap_or(0);
894 state.selected = restore_selection(&display, prior_identity.as_ref(), top_display);
895 log::debug!(
896 "jump: recompute selected={} of {} hits (top_display={})",
897 state.selected,
898 state.hits.len(),
899 top_display
900 );
901 self.jump = Some(state);
902 }
903
904 fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
905 let mut out: Vec<JumpHit> = Vec::new();
906 for h in &self.hosts_state.list {
908 out.push(JumpHit::Host(HostHit {
909 alias: h.alias.clone(),
910 hostname: h.hostname.clone(),
911 tags: h.tags.clone(),
912 provider: h.provider.clone(),
913 user: h.user.clone(),
914 identity_file: h.identity_file.clone(),
915 proxy_jump: h.proxy_jump.clone(),
916 vault_ssh: h.vault_ssh.clone(),
917 }));
918 }
919 for h in &self.hosts_state.list {
921 let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
922 for rule in rules {
923 out.push(JumpHit::Tunnel(TunnelHit {
924 alias: h.alias.clone(),
925 bind_port: rule.bind_port,
926 bind_port_str: rule.bind_port.to_string(),
927 destination: rule.display(),
928 active: self.tunnels.active.contains_key(&h.alias),
929 }));
930 }
931 }
932 for (alias, entry) in &self.container_state.cache {
935 for info in &entry.containers {
936 out.push(JumpHit::Container(ContainerHit {
937 alias: alias.clone(),
938 container_name: info.names.clone(),
939 container_id: info.id.clone(),
940 state: info.state.clone(),
941 }));
942 }
943 }
944 for snippet in &self.snippets.store.snippets {
946 out.push(JumpHit::Snippet(SnippetHit {
947 name: snippet.name.clone(),
948 command_preview: preview(&snippet.command, 40),
949 }));
950 }
951 for a in JumpAction::for_mode(mode) {
953 out.push(JumpHit::Action(*a));
954 }
955 out
956 }
957
958 pub fn record_jump_hit(&mut self, hit: &JumpHit) {
964 if self.demo_mode {
965 log::debug!("jump: record skipped (demo mode)");
966 return;
967 }
968 let mut file = jump::load_recents();
969 jump::touch_recent(&mut file, hit.identity());
970 if let Err(e) = jump::save_recents(&file) {
971 log::warn!("[purple] failed to save recents: {e}");
972 }
973 }
974
975 pub(crate) fn open_file_browser(&mut self, session: crate::file_browser::FileBrowserSession) {
979 let alias = session.alias.clone();
980 self.file_browser_session = Some(session);
981 self.set_screen(Screen::FileBrowser { alias });
982 }
983
984 pub(crate) fn close_file_browser(&mut self) {
988 if let Some(fb) = self.file_browser_session.take() {
989 self.file_browser_state
990 .host_paths
991 .insert(fb.alias, (fb.local_path, fb.remote_path));
992 }
993 self.set_screen(Screen::HostList);
994 }
995
996 pub fn flush_pending_vault_write(&mut self) -> bool {
999 if !self.vault.pending_config_write || self.is_form_open() {
1000 return false;
1001 }
1002 self.reload_hosts();
1004 true
1005 }
1006
1007 pub fn post_init(&mut self) {
1011 let outcome = crate::onboarding::evaluate();
1012 if let Some(text) = outcome.upgrade_toast {
1013 self.enqueue_sticky_toast(text);
1014 }
1015 self.scan_keys();
1019 }
1020
1021 fn enqueue_sticky_toast(&mut self, text: String) {
1022 log::debug!("[purple] enqueue sticky toast: {}", text);
1023 let msg = StatusMessage {
1024 text,
1025 class: MessageClass::Success,
1026 tick_count: 0,
1027 sticky: true,
1028 created_at: std::time::Instant::now(),
1029 };
1030 self.status_center.toast = Some(msg);
1031 }
1032
1033 pub fn notify(&mut self, text: impl Into<String>) {
1035 self.status_center.set_status(text, false);
1036 }
1037
1038 pub fn notify_error(&mut self, text: impl Into<String>) {
1040 self.status_center.set_status(text, true);
1041 }
1042
1043 pub fn notify_background(&mut self, text: impl Into<String>) {
1045 self.status_center.set_background_status(text, false);
1046 }
1047
1048 pub fn notify_background_error(&mut self, text: impl Into<String>) {
1050 self.status_center.set_background_status(text, true);
1051 }
1052
1053 pub fn notify_warning(&mut self, text: impl Into<String>) {
1065 let msg = StatusMessage {
1066 text: text.into(),
1067 class: MessageClass::Warning,
1068 tick_count: 0,
1069 sticky: false,
1070 created_at: std::time::Instant::now(),
1071 };
1072 log::debug!("toast <- Warning: {}", msg.text);
1073 self.status_center.push_toast(msg);
1074 }
1075
1076 pub fn notify_progress(&mut self, text: impl Into<String>) {
1078 self.status_center.set_sticky_status(text, false);
1079 }
1080
1081 pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
1083 self.status_center.set_sticky_status(text, true);
1084 }
1085
1086 pub fn notify_info(&mut self, text: impl Into<String>) {
1088 self.status_center.set_info_status(text);
1089 }
1090
1091 pub(crate) fn clear_status(&mut self) {
1096 self.status_center.clear_status();
1097 }
1098
1099 pub fn tick_status(&mut self) {
1106 if !self.providers.syncing.is_empty() {
1108 return;
1109 }
1110 if let Some(ref status) = self.status_center.status {
1111 if status.sticky {
1112 return;
1113 }
1114 let timeout_ms = status.timeout_ms();
1115 if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
1116 {
1117 log::debug!("footer status expired: {}", status.text);
1118 self.status_center.status = None;
1119 }
1120 }
1121 }
1122
1123 pub fn tick_toast(&mut self) {
1125 self.status_center.tick_toast();
1126 }
1127
1128 pub fn check_config_changed(&mut self) {
1132 if matches!(
1133 self.screen,
1134 Screen::AddHost
1135 | Screen::EditHost { .. }
1136 | Screen::ProviderForm { .. }
1137 | Screen::TunnelList { .. }
1138 | Screen::TunnelForm { .. }
1139 | Screen::HostDetail { .. }
1140 | Screen::SnippetPicker { .. }
1141 | Screen::SnippetForm { .. }
1142 | Screen::SnippetOutput { .. }
1143 | Screen::SnippetParamForm { .. }
1144 | Screen::FileBrowser { .. }
1145 | Screen::Containers { .. }
1146 | Screen::ConfirmDelete { .. }
1147 | Screen::ConfirmHostKeyReset { .. }
1148 | Screen::ConfirmPurgeStale { .. }
1149 | Screen::ConfirmImport { .. }
1150 | Screen::ConfirmVaultSign { .. }
1151 | Screen::TagPicker
1152 | Screen::BulkTagEditor
1153 | Screen::ThemePicker
1154 | Screen::WhatsNew(_)
1155 ) || self.tags.input.is_some()
1156 {
1157 return;
1158 }
1159 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1160 let changed = current_mtime != self.reload.last_modified
1161 || self
1162 .reload
1163 .include_mtimes
1164 .iter()
1165 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1166 || self
1167 .reload
1168 .include_dir_mtimes
1169 .iter()
1170 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
1171 if changed {
1172 log::debug!(
1173 "[config] check_config_changed: mtime drift detected on {} -> reloading",
1174 self.reload.config_path.display()
1175 );
1176 if let Ok(new_config) = SshConfigFile::parse(&self.reload.config_path) {
1177 let before_aliases = self.snapshot_alias_set();
1178 self.hosts_state.ssh_config = new_config;
1179 self.hosts_state.undo_stack.clear();
1181 log::debug!(
1183 "[config] external config change: clearing {} ping result(s) + timestamps",
1184 self.ping.status.len()
1185 );
1186 self.ping.status.clear();
1187 self.ping.last_checked.clear();
1188 self.ping.filter_down_only = false;
1189 self.ping.checked_at = None;
1190 self.reload_hosts();
1191 self.reload.last_modified = current_mtime;
1192 self.reload.include_mtimes =
1193 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1194 self.reload.include_dir_mtimes =
1195 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1196 let count = self.hosts_state.list.len();
1197 self.notify_background(crate::messages::config_reloaded(count));
1198 self.queue_new_aliases_since(&before_aliases);
1199 }
1200 }
1201 }
1202
1203 pub fn check_keys_changed(&mut self) {
1213 if self.demo_mode {
1214 return;
1215 }
1216 if matches!(
1217 self.screen,
1218 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
1219 ) {
1220 return;
1221 }
1222 let Some(home) = dirs::home_dir() else {
1223 return;
1224 };
1225 let ssh_dir = home.join(".ssh");
1226 let current_dir_mtime = reload_state::get_mtime(&ssh_dir);
1227 let dir_changed = current_dir_mtime != self.reload.keys_dir_mtime;
1228 let files_changed = self
1229 .reload
1230 .key_file_mtimes
1231 .iter()
1232 .any(|(path, old)| reload_state::get_mtime(path) != *old);
1233 if !dir_changed && !files_changed {
1234 return;
1235 }
1236 log::debug!(
1237 "[purple] check_keys_changed: drift detected on {} (dir={} files={}) -> rescan",
1238 ssh_dir.display(),
1239 dir_changed,
1240 files_changed,
1241 );
1242 let previous = self.keys.list.len();
1243 self.scan_keys();
1244 let after = self.keys.list.len();
1245 if let Some(sel) = self.keys.list_state.selected() {
1248 if sel >= after {
1249 let next = after.checked_sub(1);
1250 self.keys.list_state.select(next);
1251 }
1252 } else if after > 0 {
1253 self.keys.list_state.select(Some(0));
1254 }
1255 if previous != after {
1256 log::debug!(
1257 "[purple] check_keys_changed: rescan {} -> {} keys",
1258 previous,
1259 after
1260 );
1261 }
1262 }
1263
1264 pub fn external_config_changed(&self) -> bool {
1273 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1274 current_mtime != self.reload.last_modified
1275 || self
1276 .reload
1277 .include_mtimes
1278 .iter()
1279 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1280 || self
1281 .reload
1282 .include_dir_mtimes
1283 .iter()
1284 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1285 }
1286
1287 pub fn update_last_modified(&mut self) {
1289 self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
1290 self.reload.include_mtimes =
1291 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1292 self.reload.include_dir_mtimes =
1293 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1294 }
1295
1296 pub fn has_any_vault_role(&self) -> bool {
1298 for host in &self.hosts_state.list {
1299 if host.vault_ssh.is_some() {
1300 return true;
1301 }
1302 }
1303 for section in &self.providers.config.sections {
1304 if !section.vault_role.is_empty() {
1305 return true;
1306 }
1307 }
1308 false
1309 }
1310
1311 pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
1313 self.tunnels.poll()
1314 }
1315
1316 pub fn refresh_tunnel_bind_ports(&mut self) {
1321 let mut ports: Vec<(String, u16, u32)> = Vec::new();
1322 for (alias, tunnel) in &self.tunnels.active {
1323 let pid = tunnel.child.id();
1324 for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
1325 ports.push((alias.clone(), rule.bind_port, pid));
1326 }
1327 }
1328 self.tunnels.set_lsof_ports(ports);
1329 }
1330}
1331
1332pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
1334 if len == 0 {
1335 return;
1336 }
1337 let i = match state.selected() {
1338 Some(i) => {
1339 if forward {
1340 if i >= len - 1 { 0 } else { i + 1 }
1341 } else if i == 0 {
1342 len - 1
1343 } else {
1344 i - 1
1345 }
1346 }
1347 None => 0,
1348 };
1349 state.select(Some(i));
1350}
1351
1352pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
1354 if len == 0 {
1355 return;
1356 }
1357 let current = state.selected().unwrap_or(0);
1358 let next = (current + page_size).min(len - 1);
1359 state.select(Some(next));
1360}
1361
1362pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
1364 if len == 0 {
1365 return;
1366 }
1367 let current = state.selected().unwrap_or(0);
1368 let prev = current.saturating_sub(page_size);
1369 state.select(Some(prev));
1370}
1371
1372pub use jump::{
1376 ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, JumpMode, JumpState, RecentRef,
1377 RecentsFile, SnippetHit, SourceKind, TunnelHit,
1378};
1379
1380#[cfg(test)]
1384pub type PaletteCommand = JumpAction;
1385
1386static ALL_JUMP_ACTIONS: &[JumpAction] = &[
1393 JumpAction {
1394 key: 'a',
1395 key_str: "a",
1396 label: "Hosts: Add host",
1397 aliases: &["new", "create"],
1398 target: JumpActionTarget::Hosts,
1399 },
1400 JumpAction {
1401 key: 'A',
1402 key_str: "A",
1403 label: "Hosts: Add pattern",
1404 aliases: &["new pattern", "wildcard"],
1405 target: JumpActionTarget::Hosts,
1406 },
1407 JumpAction {
1408 key: 'e',
1409 key_str: "e",
1410 label: "Hosts: Edit host",
1411 aliases: &["modify", "change"],
1412 target: JumpActionTarget::Hosts,
1413 },
1414 JumpAction {
1415 key: 'd',
1416 key_str: "d",
1417 label: "Hosts: Delete host",
1418 aliases: &["remove", "rm"],
1419 target: JumpActionTarget::Hosts,
1420 },
1421 JumpAction {
1422 key: 'c',
1423 key_str: "c",
1424 label: "Hosts: Clone host",
1425 aliases: &["duplicate", "copy"],
1426 target: JumpActionTarget::Hosts,
1427 },
1428 JumpAction {
1429 key: 'u',
1430 key_str: "u",
1431 label: "Hosts: Undo delete",
1432 aliases: &["restore"],
1433 target: JumpActionTarget::Hosts,
1434 },
1435 JumpAction {
1436 key: 't',
1437 key_str: "t",
1438 label: "Hosts: Tag host",
1439 aliases: &["label", "category"],
1440 target: JumpActionTarget::Hosts,
1441 },
1442 JumpAction {
1443 key: 'i',
1444 key_str: "i",
1445 label: "Hosts: Show all directives",
1446 aliases: &["raw", "config", "settings"],
1447 target: JumpActionTarget::Hosts,
1448 },
1449 JumpAction {
1450 key: 'y',
1451 key_str: "y",
1452 label: "Clipboard: Copy SSH command",
1453 aliases: &["yank"],
1454 target: JumpActionTarget::Hosts,
1455 },
1456 JumpAction {
1457 key: 'x',
1458 key_str: "x",
1459 label: "Clipboard: Copy config block",
1460 aliases: &["yank config"],
1461 target: JumpActionTarget::Hosts,
1462 },
1463 JumpAction {
1464 key: 'X',
1465 key_str: "X",
1466 label: "Hosts: Purge stale hosts",
1467 aliases: &["clean", "cleanup"],
1468 target: JumpActionTarget::Hosts,
1469 },
1470 JumpAction {
1471 key: 'F',
1472 key_str: "F",
1473 label: "Files: Browse remote files",
1474 aliases: &[
1475 "browse",
1476 "filesystem",
1477 "scp",
1478 "sftp",
1479 "transfer",
1480 "explorer",
1481 "open",
1482 ],
1483 target: JumpActionTarget::Hosts,
1484 },
1485 JumpAction {
1486 key: 'C',
1487 key_str: "C",
1488 label: "Containers: List containers",
1489 aliases: &["docker", "podman", "ps", "open"],
1490 target: JumpActionTarget::Hosts,
1491 },
1492 JumpAction {
1493 key: 'K',
1494 key_str: "K",
1495 label: "Keys: Manage SSH keys",
1496 aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
1497 target: JumpActionTarget::Hosts,
1498 },
1499 JumpAction {
1500 key: 'S',
1501 key_str: "S",
1502 label: "Providers: Manage cloud sync",
1503 aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
1504 target: JumpActionTarget::Hosts,
1505 },
1506 JumpAction {
1507 key: 'V',
1508 key_str: "V",
1509 label: "Vault: Sign certificate",
1510 aliases: &["hashicorp", "ssh cert", "vault ssh"],
1511 target: JumpActionTarget::Hosts,
1512 },
1513 JumpAction {
1514 key: 'I',
1515 key_str: "I",
1516 label: "Hosts: Import from known_hosts",
1517 aliases: &["known", "import"],
1518 target: JumpActionTarget::Hosts,
1519 },
1520 JumpAction {
1521 key: 'm',
1522 key_str: "m",
1523 label: "Settings: Switch theme",
1524 aliases: &["color", "appearance", "dark", "light"],
1525 target: JumpActionTarget::Hosts,
1526 },
1527 JumpAction {
1528 key: 'n',
1529 key_str: "n",
1530 label: "Help: What's new",
1531 aliases: &["changelog", "news", "release notes"],
1532 target: JumpActionTarget::Hosts,
1533 },
1534 JumpAction {
1535 key: 'r',
1536 key_str: "r",
1537 label: "Snippets: Run snippet",
1538 aliases: &["execute", "command"],
1539 target: JumpActionTarget::Hosts,
1540 },
1541 JumpAction {
1542 key: 'R',
1543 key_str: "R",
1544 label: "Snippets: Run on all visible",
1545 aliases: &["batch", "execute all"],
1546 target: JumpActionTarget::Hosts,
1547 },
1548 JumpAction {
1549 key: 'p',
1550 key_str: "p",
1551 label: "Hosts: Ping host",
1552 aliases: &["health", "check"],
1553 target: JumpActionTarget::Hosts,
1554 },
1555 JumpAction {
1556 key: 'P',
1557 key_str: "P",
1558 label: "Hosts: Ping all hosts",
1559 aliases: &["health all"],
1560 target: JumpActionTarget::Hosts,
1561 },
1562 JumpAction {
1563 key: '!',
1564 key_str: "!",
1565 label: "Hosts: Show down only",
1566 aliases: &["filter offline", "down only"],
1567 target: JumpActionTarget::Hosts,
1568 },
1569 JumpAction {
1573 key: 'T',
1574 key_str: "T",
1575 label: "Tunnels: Manage tunnels",
1576 aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
1577 target: JumpActionTarget::Hosts,
1578 },
1579 JumpAction {
1580 key: 'a',
1581 key_str: "a",
1582 label: "Tunnels: Add tunnel",
1583 aliases: &["new tunnel", "create tunnel", "forward"],
1584 target: JumpActionTarget::Tunnels,
1585 },
1586 JumpAction {
1587 key: 'e',
1588 key_str: "e",
1589 label: "Tunnels: Edit tunnel",
1590 aliases: &["modify tunnel"],
1591 target: JumpActionTarget::Tunnels,
1592 },
1593 JumpAction {
1594 key: 'd',
1595 key_str: "d",
1596 label: "Tunnels: Delete tunnel",
1597 aliases: &["remove tunnel"],
1598 target: JumpActionTarget::Tunnels,
1599 },
1600 JumpAction {
1601 key: 's',
1602 key_str: "s",
1603 label: "Tunnels: Sort",
1604 aliases: &["order tunnels"],
1605 target: JumpActionTarget::Tunnels,
1606 },
1607 JumpAction {
1608 key: 'R',
1609 key_str: "R",
1610 label: "Containers: Refresh all hosts",
1611 aliases: &["reload containers", "fetch", "rescan"],
1612 target: JumpActionTarget::Containers,
1613 },
1614 JumpAction {
1615 key: 's',
1616 key_str: "s",
1617 label: "Containers: Cycle sort",
1618 aliases: &["order containers", "sort by host", "sort by name"],
1619 target: JumpActionTarget::Containers,
1620 },
1621 JumpAction {
1622 key: 'v',
1623 key_str: "v",
1624 label: "Containers: Toggle detail panel",
1625 aliases: &["show details", "hide details", "compact view"],
1626 target: JumpActionTarget::Containers,
1627 },
1628 JumpAction {
1632 key: 'c',
1633 key_str: "c",
1634 label: "Keys: Copy public key",
1635 aliases: &["yank", "clipboard", "pubkey"],
1636 target: JumpActionTarget::Keys,
1637 },
1638 JumpAction {
1639 key: 'p',
1640 key_str: "p",
1641 label: "Keys: Push to host",
1642 aliases: &["install", "ssh-copy-id", "deploy", "upload"],
1643 target: JumpActionTarget::Keys,
1644 },
1645 JumpAction {
1646 key: 'V',
1647 key_str: "V",
1648 label: "Keys: Sign Vault SSH certificate",
1649 aliases: &["vault", "renew cert", "sign"],
1650 target: JumpActionTarget::Keys,
1651 },
1652];
1653
1654pub const PALETTE_PER_SECTION_CAP: usize = 32;
1659
1660pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
1663 if let Some((prefix, rest)) = query.split_once(':') {
1664 let scope = match prefix.trim() {
1665 "user" => Some(QueryScope::User),
1666 "host" => Some(QueryScope::Hostname),
1667 "proxy" => Some(QueryScope::ProxyJump),
1668 "vault" => Some(QueryScope::VaultSsh),
1669 "tag" => Some(QueryScope::Tag),
1670 _ => None,
1671 };
1672 if scope.is_some() {
1673 return (scope, rest.trim_start());
1674 }
1675 }
1676 (None, query)
1677}
1678
1679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1680pub enum QueryScope {
1681 User,
1682 Hostname,
1683 ProxyJump,
1684 VaultSsh,
1685 Tag,
1686}
1687
1688fn preview(s: &str, max: usize) -> String {
1690 let s = s.replace('\n', " ");
1691 let chars: Vec<char> = s.chars().collect();
1692 if chars.len() <= max {
1693 s
1694 } else {
1695 let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
1696 out.push_str("...");
1697 out
1698 }
1699}
1700
1701fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
1706 let scope = scope?;
1707 match (hit, scope) {
1708 (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
1709 (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
1710 Some(vec![&h.hostname])
1711 }
1712 (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
1713 Some(vec![&h.proxy_jump])
1714 }
1715 (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
1716 (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
1717 _ => None,
1719 }
1720}
1721
1722pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
1727 if query.is_empty() {
1728 return None;
1729 }
1730 let q = query.to_lowercase();
1731 let alias_hit = host.alias.to_lowercase().contains(&q);
1732 let hostname_hit = host.hostname.to_lowercase().contains(&q);
1733 if alias_hit || hostname_hit {
1734 return None;
1735 }
1736 if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
1737 return Some(MatchSource::User);
1738 }
1739 if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
1740 return Some(MatchSource::ProxyJump);
1741 }
1742 if let Some(role) = &host.vault_ssh {
1743 if role.to_lowercase().contains(&q) {
1744 return Some(MatchSource::VaultSsh);
1745 }
1746 }
1747 if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
1748 return Some(MatchSource::IdentityFile);
1749 }
1750 None
1751}
1752
1753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1754pub enum MatchSource {
1755 User,
1756 ProxyJump,
1757 VaultSsh,
1758 IdentityFile,
1759}
1760
1761fn kind_rank(k: SourceKind) -> u8 {
1762 match k {
1763 SourceKind::Host => 0,
1764 SourceKind::Tunnel => 1,
1765 SourceKind::Container => 2,
1766 SourceKind::Snippet => 3,
1767 SourceKind::Action => 4,
1768 }
1769}
1770
1771fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
1776 if let Some(target) = prior {
1777 if let Some(idx) = hits.iter().position(|h| &h.identity() == target) {
1778 return idx;
1779 }
1780 }
1781 fallback.min(hits.len().saturating_sub(1))
1782}
1783
1784impl JumpAction {
1785 #[cfg(test)]
1786 pub fn all() -> &'static [JumpAction] {
1787 ALL_JUMP_ACTIONS
1788 }
1789
1790 pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
1794 ALL_JUMP_ACTIONS
1795 }
1796}
1797
1798#[cfg(test)]
1799mod tests;