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) env: std::sync::Arc<crate::runtime::env::Env>,
207
208 pub(crate) jump: Option<JumpState>,
210}
211
212impl App {
213 pub fn new(config: SshConfigFile) -> Self {
217 #[cfg(test)]
218 let env = std::sync::Arc::new(crate::runtime::env::Env::sandboxed());
219 #[cfg(not(test))]
220 let env = std::sync::Arc::new(crate::runtime::env::Env::from_process());
221 Self::with_env(config, env)
222 }
223
224 pub fn with_env(config: SshConfigFile, env: std::sync::Arc<crate::runtime::env::Env>) -> Self {
228 let hosts = config.host_entries();
229 let patterns = config.pattern_entries();
230 let display_list = Self::build_display_list_from(&config, &hosts, &patterns);
231
232 let initial_selection = display_list.iter().position(|item| {
233 matches!(
234 item,
235 HostListItem::Host { .. } | HostListItem::Pattern { .. }
236 )
237 });
238
239 let reload = ReloadState::from_config(&config);
240 let hosts_state = HostState::from_config(config, hosts, patterns, display_list);
241
242 Self {
243 screen: Screen::HostList,
244 top_page: TopPage::default(),
245 running: true,
246 hosts_state,
247 status_center: StatusCenter::default(),
248 ui: UiSelection::new_with_initial_selection(initial_selection),
249 search: SearchState::default(),
250 reload,
251 conflict: ConflictState::default(),
252 keys: KeysState {
253 list: Vec::new(),
254 list_state: ratatui::widgets::ListState::default(),
255 activity: crate::key_activity::KeyActivityLog::load(),
256 push: KeyPushState::default(),
257 },
258 tags: TagState::default(),
259 forms: FormState::default(),
260 history: ConnectionHistory::load(),
261 providers: ProviderState::load(),
262 ping: PingState::from_preferences(env.paths()),
263 vault: VaultState::default(),
264 tunnels: TunnelState::default(),
265 snippets: SnippetState::with_store_loaded(),
266 update: UpdateState::with_current_hint(),
267 bw_session: None,
268 file_browser_state: FileBrowserState::default(),
269 file_browser_session: None,
270 container_state: ContainerState {
271 cache: crate::containers::load_container_cache(env.paths()),
272 ..ContainerState::default()
273 },
274 container_session: None,
275 containers_overview: ContainersOverviewState::default(),
276 demo_mode: false,
277 env,
278 jump: None,
279 }
280 }
281
282 pub(crate) fn env(&self) -> &crate::runtime::env::Env {
284 &self.env
285 }
286
287 pub fn record_key_use(&mut self, alias: &str, now: u64) {
293 crate::key_activity::record_and_flush(&mut self.keys.activity, alias, now);
294 }
295
296 pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
300 self.hosts_state
301 .list
302 .iter()
303 .map(|h| h.alias.clone())
304 .collect()
305 }
306
307 pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
313 let new_aliases: Vec<String> = self
314 .hosts_state
315 .list
316 .iter()
317 .filter(|h| !before_aliases.contains(&h.alias))
318 .map(|h| h.alias.clone())
319 .collect();
320 for alias in new_aliases {
321 self.container_state.queue_fetch(alias);
322 }
323 }
324
325 pub fn reload_hosts(&mut self) {
327 let had_pending_vault_write = self.vault.pending_config_write;
328 let mut flushed_vault_write = false;
341 if self.vault.pending_config_write && !self.is_form_open() {
342 if self.external_config_changed() {
343 self.notify_error(
344 crate::messages::vault_config_skipped_external_change().to_string(),
345 );
346 log::warn!(
347 "[config] reload_hosts: skipping deferred vault write — external config changed"
348 );
349 } else {
350 match self.hosts_state.ssh_config.write() {
351 Ok(()) => flushed_vault_write = true,
352 Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
353 }
354 }
355 }
356 self.vault.pending_config_write = false;
359 log::debug!(
360 "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
361 );
362 let had_search = self.search.query.take();
363 let selected_alias = self
364 .selected_host()
365 .map(|h| h.alias.clone())
366 .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
367
368 self.tunnels.summaries_cache.clear();
369 self.hosts_state.render_cache.invalidate();
370 self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
371 self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
372 let valid_for_certs: std::collections::HashSet<&str> = self
375 .hosts_state
376 .list
377 .iter()
378 .map(|h| h.alias.as_str())
379 .collect();
380 self.vault
381 .cert_cache
382 .retain(|alias, _| valid_for_certs.contains(alias.as_str()));
383 self.vault
384 .cert_checks_in_flight
385 .retain(|alias| valid_for_certs.contains(alias.as_str()));
386 if self.hosts_state.sort_mode == SortMode::Original
387 && matches!(self.hosts_state.group_by, GroupBy::None)
388 {
389 self.hosts_state.display_list = Self::build_display_list_from(
390 &self.hosts_state.ssh_config,
391 &self.hosts_state.list,
392 &self.hosts_state.patterns,
393 );
394 } else {
395 self.apply_sort();
396 }
397
398 if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
400 self.set_screen(Screen::HostList);
401 self.forms.bulk_tag_editor = BulkTagEditorState::default();
402 }
403
404 self.hosts_state.multi_select.clear();
406
407 let valid_aliases: std::collections::HashSet<&str> = self
409 .hosts_state
410 .list
411 .iter()
412 .map(|h| h.alias.as_str())
413 .collect();
414
415 let pre_container_cache = self.container_state.cache.len();
422 self.container_state
423 .cache
424 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
425 let dropped_container_hosts =
426 pre_container_cache.saturating_sub(self.container_state.cache.len());
427 if dropped_container_hosts > 0 {
428 log::debug!(
429 "[purple] reload_hosts: dropped {} orphan container_cache host(s)",
430 dropped_container_hosts
431 );
432 crate::containers::save_container_cache(
433 self.env().paths(),
434 &self.container_state.cache,
435 );
436 }
437
438 let valid_container_ids: std::collections::HashSet<String> = self
442 .container_state
443 .cache
444 .values()
445 .flat_map(|e| e.containers.iter().map(|c| c.id.clone()))
446 .collect();
447 let pre_inspect = self.containers_overview.inspect_cache.entries.len();
448 self.containers_overview
449 .inspect_cache
450 .entries
451 .retain(|id, _| valid_container_ids.contains(id));
452 self.containers_overview
453 .inspect_cache
454 .in_flight
455 .retain(|id| valid_container_ids.contains(id));
456 self.containers_overview
459 .logs_cache
460 .entries
461 .retain(|id, _| valid_container_ids.contains(id));
462 self.containers_overview
463 .logs_cache
464 .in_flight
465 .retain(|id| valid_container_ids.contains(id));
466 self.containers_overview
473 .auto_list_in_flight
474 .retain(|alias| valid_aliases.contains(alias.as_str()));
475 if let Some(batch) = self.containers_overview.refresh_batch.as_mut() {
479 let pre = batch.in_flight_aliases.len();
480 batch
481 .in_flight_aliases
482 .retain(|alias| valid_aliases.contains(alias.as_str()));
483 let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
484 if dropped > 0 {
485 log::debug!(
486 "[purple] reload_hosts: dropped {} orphan refresh_batch in_flight alias(es)",
487 dropped
488 );
489 }
490 }
491 {
496 let mut sign = match self.vault.sign_in_flight.lock() {
497 Ok(g) => g,
498 Err(p) => p.into_inner(),
499 };
500 let pre = sign.len();
501 sign.retain(|alias| valid_aliases.contains(alias.as_str()));
502 let dropped = pre.saturating_sub(sign.len());
503 if dropped > 0 {
504 log::debug!(
505 "[purple] reload_hosts: dropped {} orphan sign_in_flight alias(es)",
506 dropped
507 );
508 }
509 }
510 let pre_paths = self.file_browser_state.host_paths.len();
513 self.file_browser_state
514 .host_paths
515 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
516 let dropped_paths = pre_paths.saturating_sub(self.file_browser_state.host_paths.len());
517 if dropped_paths > 0 {
518 log::debug!(
519 "[purple] reload_hosts: dropped {} orphan file_browser host_paths entrie(s)",
520 dropped_paths
521 );
522 }
523 let pre_demo = self.tunnels.demo_live_snapshots.len();
527 self.tunnels
528 .demo_live_snapshots
529 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
530 let dropped_demo = pre_demo.saturating_sub(self.tunnels.demo_live_snapshots.len());
531 if dropped_demo > 0 {
532 log::debug!(
533 "[purple] reload_hosts: dropped {} orphan demo_live_snapshots entrie(s)",
534 dropped_demo
535 );
536 }
537 let pre_collapsed = self.containers_overview.collapsed_hosts.len();
541 self.containers_overview
542 .collapsed_hosts
543 .retain(|alias| valid_aliases.contains(alias.as_str()));
544 let dropped_collapsed =
545 pre_collapsed.saturating_sub(self.containers_overview.collapsed_hosts.len());
546 if dropped_collapsed > 0 {
547 log::debug!(
548 "[purple] reload_hosts: dropped {} orphan collapsed_hosts entrie(s)",
549 dropped_collapsed
550 );
551 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
552 self.env().paths(),
553 &self.containers_overview.collapsed_hosts,
554 ) {
555 log::warn!("[config] failed to save collapsed_hosts after prune: {e}");
556 }
557 }
558 let dropped_inspect =
559 pre_inspect.saturating_sub(self.containers_overview.inspect_cache.entries.len());
560 if dropped_inspect > 0 {
561 log::debug!(
562 "[purple] reload_hosts: dropped {} orphan inspect_cache entrie(s)",
563 dropped_inspect
564 );
565 }
566
567 let pre_status = self.ping.status.len();
568 let pre_checked = self.ping.last_checked.len();
569 self.ping
570 .status
571 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
572 self.ping
573 .last_checked
574 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
575 let dropped = pre_status.saturating_sub(self.ping.status.len())
576 + pre_checked.saturating_sub(self.ping.last_checked.len());
577 if dropped > 0 {
578 log::debug!(
579 "[purple] reload_hosts: pruned {} orphan ping entrie(s); {} aliases remain",
580 dropped,
581 valid_aliases.len()
582 );
583 }
584
585 if let Some(query) = had_search {
587 self.search.query = Some(query);
588 self.apply_filter();
589 } else {
590 self.search.query = None;
591 self.search.filtered_indices.clear();
592 self.search.filtered_pattern_indices.clear();
593 if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
595 self.ui.list_state.select(None);
596 } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
597 matches!(
598 item,
599 HostListItem::Host { .. } | HostListItem::Pattern { .. }
600 )
601 }) {
602 let current = self.ui.list_state.selected().unwrap_or(0);
603 if current >= self.hosts_state.display_list.len()
604 || !matches!(
605 self.hosts_state.display_list.get(current),
606 Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
607 )
608 {
609 self.ui.list_state.select(Some(pos));
610 }
611 } else {
612 self.ui.list_state.select(None);
613 }
614 }
615
616 if let Some(alias) = selected_alias {
618 self.select_host_by_alias(&alias);
619 }
620
621 log::debug!(
622 "[config] reload_hosts: hosts={} patterns={} display_items={}",
623 self.hosts_state.list.len(),
624 self.hosts_state.patterns.len(),
625 self.hosts_state.display_list.len(),
626 );
627 }
628
629 pub fn refresh_cert_cache(&mut self, alias: &str) {
640 if crate::demo_flag::is_demo() {
641 return;
642 }
643 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
644 self.vault.cert_cache.remove(alias);
645 return;
646 };
647 let role_some = crate::vault_ssh::resolve_vault_role(
648 host.vault_ssh.as_deref(),
649 host.provider.as_deref(),
650 host.provider_label.as_deref(),
651 &self.providers.config,
652 )
653 .is_some();
654 if !role_some {
655 self.vault.cert_cache.remove(alias);
656 return;
657 }
658 let cert_path = match crate::vault_ssh::resolve_cert_path(
659 self.env().paths(),
660 alias,
661 &host.certificate_file,
662 ) {
663 Ok(p) => p,
664 Err(_) => {
665 self.vault.cert_cache.remove(alias);
666 return;
667 }
668 };
669 let status = crate::vault_ssh::check_cert_validity(self.env(), &cert_path);
670 let mtime = std::fs::metadata(&cert_path)
671 .ok()
672 .and_then(|m| m.modified().ok());
673 self.vault.cert_cache.insert(
674 alias.to_string(),
675 (std::time::Instant::now(), status, mtime),
676 );
677 }
678
679 #[cfg(test)]
686 pub fn sorted_provider_names(&self) -> Vec<String> {
687 self.providers.sorted_names()
688 }
689
690 pub fn is_form_open(&self) -> bool {
692 matches!(
693 self.screen,
694 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
695 )
696 }
697
698 pub fn open_jump(&mut self, mode: JumpMode) {
701 log::debug!("jump: open mode={:?}", mode);
702 let mut state = JumpState::for_mode(mode);
703 let recents_file = jump::load_recents();
704 state.recents = self.resolve_recents(&recents_file);
705 self.jump = Some(state);
706 self.recompute_jump_hits();
707 }
708
709 pub(crate) fn close_jump(&mut self) {
713 self.jump = None;
714 }
715
716 fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
719 let mode = self
720 .jump
721 .as_ref()
722 .map(|p| p.mode)
723 .unwrap_or(JumpMode::Hosts);
724 let mut out = Vec::with_capacity(file.entries.len());
725 for entry in &file.entries {
726 if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
727 out.push(hit);
728 }
729 }
730 out
731 }
732
733 #[cfg(test)]
737 pub(crate) fn resolve_recent_ref_for_test(
738 &self,
739 r: &RecentRef,
740 mode: JumpMode,
741 ) -> Option<JumpHit> {
742 self.resolve_recent_ref(r, mode)
743 }
744
745 fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
746 match r.kind {
747 SourceKind::Action => {
748 let key_char = r.key.chars().next()?;
749 let actions = JumpAction::for_mode(mode);
750 actions
751 .iter()
752 .find(|a| a.key == key_char)
753 .copied()
754 .map(JumpHit::Action)
755 }
756 SourceKind::Host => {
757 let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
758 Some(JumpHit::Host(HostHit {
759 alias: host.alias.clone(),
760 hostname: host.hostname.clone(),
761 tags: host.tags.clone(),
762 provider: host.provider.clone(),
763 user: host.user.clone(),
764 identity_file: host.identity_file.clone(),
765 proxy_jump: host.proxy_jump.clone(),
766 vault_ssh: host.vault_ssh.clone(),
767 }))
768 }
769 SourceKind::Tunnel => {
770 let (alias, port_str) = r.key.split_once(':')?;
771 let port: u16 = port_str.parse().ok()?;
772 let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
773 let rule = rules.iter().find(|r| r.bind_port == port)?;
774 Some(JumpHit::Tunnel(TunnelHit {
775 alias: alias.to_string(),
776 bind_port: rule.bind_port,
777 bind_port_str: rule.bind_port.to_string(),
778 destination: rule.display(),
779 active: self.tunnels.active.contains_key(alias),
780 }))
781 }
782 SourceKind::Container => {
783 let (alias, name) = r.key.split_once('/')?;
784 let entry = self.container_state.cache.get(alias)?;
785 let info = entry.containers.iter().find(|c| c.names == name)?;
786 Some(JumpHit::Container(ContainerHit {
787 alias: alias.to_string(),
788 container_name: info.names.clone(),
789 container_id: info.id.clone(),
790 state: info.state.clone(),
791 }))
792 }
793 SourceKind::Snippet => {
794 let snippet = self.snippets.store.get(&r.key)?;
795 Some(JumpHit::Snippet(SnippetHit {
796 name: snippet.name.clone(),
797 command_preview: preview(&snippet.command, 40),
798 }))
799 }
800 }
801 }
802
803 pub fn recompute_jump_hits(&mut self) {
809 let Some(mut state) = self.jump.take() else {
810 return;
811 };
812 let prior_identity = state
816 .visible_hits()
817 .get(state.selected)
818 .map(|h| h.identity());
819
820 let candidates = self.collect_jump_candidates(state.mode);
821 if state.query.is_empty() {
822 state.hits = candidates;
823 state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
824 self.jump = Some(state);
825 return;
826 }
827
828 let (scope, effective_query) = parse_query_scope(&state.query);
833
834 use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
835 use nucleo_matcher::{Config, Matcher, Utf32Str};
836 let matcher_state = state
837 .matcher
838 .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
839 let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
840 let mut buf: Vec<char> = Vec::new();
841 let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
842 for hit in candidates {
843 let mut best: u32 = 0;
844 let scoped_haystacks = scoped_haystacks_for(&hit, scope);
848 let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
849 hs
850 } else {
851 hit.haystacks()
852 };
853 for haystack in haystacks {
854 buf.clear();
855 let chars = Utf32Str::new(haystack, &mut buf);
856 if let Some(score) = pattern.score(chars, matcher_state) {
857 best = best.max(score);
858 }
859 }
860 if let JumpHit::Action(a) = &hit {
866 let single = effective_query.chars().next();
867 if effective_query.chars().count() == 1
868 && single
869 .map(|c| c.eq_ignore_ascii_case(&a.key))
870 .unwrap_or(false)
871 {
872 let mode_match = matches!(
873 (state.mode, a.target),
874 (JumpMode::Hosts, JumpActionTarget::Hosts)
875 | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
876 | (JumpMode::Containers, JumpActionTarget::Containers)
877 | (JumpMode::Keys, JumpActionTarget::Keys)
878 );
879 let bump = if mode_match { 20_000 } else { 10_000 };
880 best = best.saturating_add(bump);
881 }
882 }
883 let floor = match &hit {
887 JumpHit::Action(_) => jump::PALETTE_ACTION_FLOOR,
888 _ => 1,
889 };
890 if best >= floor {
891 scored.push((hit, best));
892 }
893 }
894 scored.sort_by(|a, b| {
897 b.1.cmp(&a.1)
898 .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
899 });
900 let mut per_kind: [usize; 5] = [0; 5];
903 let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
904 for (hit, _) in scored {
905 let slot = kind_rank(hit.kind()) as usize;
906 if per_kind[slot] < PALETTE_PER_SECTION_CAP {
907 per_kind[slot] += 1;
908 filtered.push(hit);
909 }
910 }
911 state.hits = filtered;
912 let display = state.visible_hits();
921 let top_display = state
922 .hits
923 .first()
924 .map(|h| h.kind())
925 .and_then(|k| display.iter().position(|h| h.kind() == k))
926 .unwrap_or(0);
927 state.selected = restore_selection(&display, prior_identity.as_ref(), top_display);
928 log::debug!(
929 "jump: recompute selected={} of {} hits (top_display={})",
930 state.selected,
931 state.hits.len(),
932 top_display
933 );
934 self.jump = Some(state);
935 }
936
937 fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
938 let mut out: Vec<JumpHit> = Vec::new();
939 for h in &self.hosts_state.list {
941 out.push(JumpHit::Host(HostHit {
942 alias: h.alias.clone(),
943 hostname: h.hostname.clone(),
944 tags: h.tags.clone(),
945 provider: h.provider.clone(),
946 user: h.user.clone(),
947 identity_file: h.identity_file.clone(),
948 proxy_jump: h.proxy_jump.clone(),
949 vault_ssh: h.vault_ssh.clone(),
950 }));
951 }
952 for h in &self.hosts_state.list {
954 let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
955 for rule in rules {
956 out.push(JumpHit::Tunnel(TunnelHit {
957 alias: h.alias.clone(),
958 bind_port: rule.bind_port,
959 bind_port_str: rule.bind_port.to_string(),
960 destination: rule.display(),
961 active: self.tunnels.active.contains_key(&h.alias),
962 }));
963 }
964 }
965 for (alias, entry) in &self.container_state.cache {
968 for info in &entry.containers {
969 out.push(JumpHit::Container(ContainerHit {
970 alias: alias.clone(),
971 container_name: info.names.clone(),
972 container_id: info.id.clone(),
973 state: info.state.clone(),
974 }));
975 }
976 }
977 for snippet in &self.snippets.store.snippets {
979 out.push(JumpHit::Snippet(SnippetHit {
980 name: snippet.name.clone(),
981 command_preview: preview(&snippet.command, 40),
982 }));
983 }
984 for a in JumpAction::for_mode(mode) {
986 out.push(JumpHit::Action(*a));
987 }
988 out
989 }
990
991 pub fn record_jump_hit(&mut self, hit: &JumpHit) {
997 if self.demo_mode {
998 log::debug!("jump: record skipped (demo mode)");
999 return;
1000 }
1001 let mut file = jump::load_recents();
1002 jump::touch_recent(&mut file, hit.identity());
1003 if let Err(e) = jump::save_recents(&file) {
1004 log::warn!("[purple] failed to save recents: {e}");
1005 }
1006 }
1007
1008 pub(crate) fn open_file_browser(&mut self, session: crate::file_browser::FileBrowserSession) {
1012 let alias = session.alias.clone();
1013 self.file_browser_session = Some(session);
1014 self.set_screen(Screen::FileBrowser { alias });
1015 }
1016
1017 pub(crate) fn close_file_browser(&mut self) {
1021 if let Some(fb) = self.file_browser_session.take() {
1022 self.file_browser_state
1023 .host_paths
1024 .insert(fb.alias, (fb.local_path, fb.remote_path));
1025 }
1026 self.set_screen(Screen::HostList);
1027 }
1028
1029 pub fn flush_pending_vault_write(&mut self) -> bool {
1032 if !self.vault.pending_config_write || self.is_form_open() {
1033 return false;
1034 }
1035 self.reload_hosts();
1037 true
1038 }
1039
1040 pub fn post_init(&mut self) {
1044 let outcome = crate::onboarding::evaluate(self.env().paths());
1045 if let Some(text) = outcome.upgrade_toast {
1046 self.enqueue_sticky_toast(text);
1047 }
1048 self.scan_keys();
1052 }
1053
1054 fn enqueue_sticky_toast(&mut self, text: String) {
1055 log::debug!("[purple] enqueue sticky toast: {}", text);
1056 let msg = StatusMessage {
1057 text,
1058 class: MessageClass::Success,
1059 tick_count: 0,
1060 sticky: true,
1061 created_at: std::time::Instant::now(),
1062 };
1063 self.status_center.toast = Some(msg);
1064 }
1065
1066 pub fn notify(&mut self, text: impl Into<String>) {
1068 self.status_center.set_status(text, false);
1069 }
1070
1071 pub fn notify_error(&mut self, text: impl Into<String>) {
1073 self.status_center.set_status(text, true);
1074 }
1075
1076 pub fn notify_background(&mut self, text: impl Into<String>) {
1078 self.status_center.set_background_status(text, false);
1079 }
1080
1081 pub fn notify_background_error(&mut self, text: impl Into<String>) {
1083 self.status_center.set_background_status(text, true);
1084 }
1085
1086 pub fn notify_warning(&mut self, text: impl Into<String>) {
1098 let msg = StatusMessage {
1099 text: text.into(),
1100 class: MessageClass::Warning,
1101 tick_count: 0,
1102 sticky: false,
1103 created_at: std::time::Instant::now(),
1104 };
1105 log::debug!("toast <- Warning: {}", msg.text);
1106 self.status_center.push_toast(msg);
1107 }
1108
1109 pub fn notify_progress(&mut self, text: impl Into<String>) {
1111 self.status_center.set_sticky_status(text, false);
1112 }
1113
1114 pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
1116 self.status_center.set_sticky_status(text, true);
1117 }
1118
1119 pub fn notify_info(&mut self, text: impl Into<String>) {
1121 self.status_center.set_info_status(text);
1122 }
1123
1124 pub(crate) fn clear_status(&mut self) {
1129 self.status_center.clear_status();
1130 }
1131
1132 pub fn tick_status(&mut self) {
1139 if !self.providers.syncing.is_empty() {
1141 return;
1142 }
1143 if let Some(ref status) = self.status_center.status {
1144 if status.sticky {
1145 return;
1146 }
1147 let timeout_ms = status.timeout_ms();
1148 if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
1149 {
1150 log::debug!("footer status expired: {}", status.text);
1151 self.status_center.status = None;
1152 }
1153 }
1154 }
1155
1156 pub fn tick_toast(&mut self) {
1158 self.status_center.tick_toast();
1159 }
1160
1161 pub fn check_config_changed(&mut self) {
1165 if matches!(
1166 self.screen,
1167 Screen::AddHost
1168 | Screen::EditHost { .. }
1169 | Screen::ProviderForm { .. }
1170 | Screen::TunnelList { .. }
1171 | Screen::TunnelForm { .. }
1172 | Screen::HostDetail { .. }
1173 | Screen::SnippetPicker { .. }
1174 | Screen::SnippetForm { .. }
1175 | Screen::SnippetOutput { .. }
1176 | Screen::SnippetParamForm { .. }
1177 | Screen::FileBrowser { .. }
1178 | Screen::Containers { .. }
1179 | Screen::ConfirmDelete { .. }
1180 | Screen::ConfirmHostKeyReset { .. }
1181 | Screen::ConfirmPurgeStale { .. }
1182 | Screen::ConfirmImport { .. }
1183 | Screen::ConfirmVaultSign { .. }
1184 | Screen::TagPicker
1185 | Screen::BulkTagEditor
1186 | Screen::ThemePicker
1187 | Screen::WhatsNew(_)
1188 ) || self.tags.input.is_some()
1189 {
1190 return;
1191 }
1192 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1193 let changed = current_mtime != self.reload.last_modified
1194 || self
1195 .reload
1196 .include_mtimes
1197 .iter()
1198 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1199 || self
1200 .reload
1201 .include_dir_mtimes
1202 .iter()
1203 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
1204 if changed {
1205 log::debug!(
1206 "[config] check_config_changed: mtime drift detected on {} -> reloading",
1207 self.reload.config_path.display()
1208 );
1209 if let Ok(new_config) = SshConfigFile::parse(&self.reload.config_path) {
1210 let before_aliases = self.snapshot_alias_set();
1211 self.hosts_state.ssh_config = new_config;
1212 self.hosts_state.undo_stack.clear();
1214 log::debug!(
1216 "[config] external config change: clearing {} ping result(s) + timestamps",
1217 self.ping.status.len()
1218 );
1219 self.ping.status.clear();
1220 self.ping.last_checked.clear();
1221 self.ping.filter_down_only = false;
1222 self.ping.checked_at = None;
1223 self.reload_hosts();
1224 self.reload.last_modified = current_mtime;
1225 self.reload.include_mtimes =
1226 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1227 self.reload.include_dir_mtimes =
1228 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1229 let count = self.hosts_state.list.len();
1230 self.notify_background(crate::messages::config_reloaded(count));
1231 self.queue_new_aliases_since(&before_aliases);
1232 }
1233 }
1234 }
1235
1236 pub fn check_keys_changed(&mut self) {
1246 if self.demo_mode {
1247 return;
1248 }
1249 if matches!(
1250 self.screen,
1251 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
1252 ) {
1253 return;
1254 }
1255 let Some(ssh_dir) = self.env().paths().map(crate::runtime::env::Paths::ssh_dir) else {
1256 return;
1257 };
1258 let current_dir_mtime = reload_state::get_mtime(&ssh_dir);
1259 let dir_changed = current_dir_mtime != self.reload.keys_dir_mtime;
1260 let files_changed = self
1261 .reload
1262 .key_file_mtimes
1263 .iter()
1264 .any(|(path, old)| reload_state::get_mtime(path) != *old);
1265 if !dir_changed && !files_changed {
1266 return;
1267 }
1268 log::debug!(
1269 "[purple] check_keys_changed: drift detected on {} (dir={} files={}) -> rescan",
1270 ssh_dir.display(),
1271 dir_changed,
1272 files_changed,
1273 );
1274 let previous = self.keys.list.len();
1275 self.scan_keys();
1276 let after = self.keys.list.len();
1277 if let Some(sel) = self.keys.list_state.selected() {
1280 if sel >= after {
1281 let next = after.checked_sub(1);
1282 self.keys.list_state.select(next);
1283 }
1284 } else if after > 0 {
1285 self.keys.list_state.select(Some(0));
1286 }
1287 if previous != after {
1288 log::debug!(
1289 "[purple] check_keys_changed: rescan {} -> {} keys",
1290 previous,
1291 after
1292 );
1293 }
1294 }
1295
1296 pub fn external_config_changed(&self) -> bool {
1305 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1306 current_mtime != self.reload.last_modified
1307 || self
1308 .reload
1309 .include_mtimes
1310 .iter()
1311 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1312 || self
1313 .reload
1314 .include_dir_mtimes
1315 .iter()
1316 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1317 }
1318
1319 pub fn update_last_modified(&mut self) {
1321 self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
1322 self.reload.include_mtimes =
1323 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1324 self.reload.include_dir_mtimes =
1325 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1326 }
1327
1328 pub fn has_any_vault_role(&self) -> bool {
1330 for host in &self.hosts_state.list {
1331 if host.vault_ssh.is_some() {
1332 return true;
1333 }
1334 }
1335 for section in &self.providers.config.sections {
1336 if !section.vault_role.is_empty() {
1337 return true;
1338 }
1339 }
1340 false
1341 }
1342
1343 pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
1345 self.tunnels.poll()
1346 }
1347
1348 pub fn refresh_tunnel_bind_ports(&mut self) {
1353 let mut ports: Vec<(String, u16, u32)> = Vec::new();
1354 for (alias, tunnel) in &self.tunnels.active {
1355 let pid = tunnel.child.id();
1356 for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
1357 ports.push((alias.clone(), rule.bind_port, pid));
1358 }
1359 }
1360 self.tunnels.set_lsof_ports(ports);
1361 }
1362}
1363
1364pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
1366 if len == 0 {
1367 return;
1368 }
1369 let i = match state.selected() {
1370 Some(i) => {
1371 if forward {
1372 if i >= len - 1 { 0 } else { i + 1 }
1373 } else if i == 0 {
1374 len - 1
1375 } else {
1376 i - 1
1377 }
1378 }
1379 None => 0,
1380 };
1381 state.select(Some(i));
1382}
1383
1384pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
1386 if len == 0 {
1387 return;
1388 }
1389 let current = state.selected().unwrap_or(0);
1390 let next = (current + page_size).min(len - 1);
1391 state.select(Some(next));
1392}
1393
1394pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
1396 if len == 0 {
1397 return;
1398 }
1399 let current = state.selected().unwrap_or(0);
1400 let prev = current.saturating_sub(page_size);
1401 state.select(Some(prev));
1402}
1403
1404pub use jump::{
1408 ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, JumpMode, JumpState, RecentRef,
1409 RecentsFile, SnippetHit, SourceKind, TunnelHit,
1410};
1411
1412#[cfg(test)]
1416pub type PaletteCommand = JumpAction;
1417
1418static ALL_JUMP_ACTIONS: &[JumpAction] = &[
1425 JumpAction {
1426 key: 'a',
1427 key_str: "a",
1428 label: "Hosts: Add host",
1429 aliases: &["new", "create"],
1430 target: JumpActionTarget::Hosts,
1431 },
1432 JumpAction {
1433 key: 'A',
1434 key_str: "A",
1435 label: "Hosts: Add pattern",
1436 aliases: &["new pattern", "wildcard"],
1437 target: JumpActionTarget::Hosts,
1438 },
1439 JumpAction {
1440 key: 'e',
1441 key_str: "e",
1442 label: "Hosts: Edit host",
1443 aliases: &["modify", "change"],
1444 target: JumpActionTarget::Hosts,
1445 },
1446 JumpAction {
1447 key: 'd',
1448 key_str: "d",
1449 label: "Hosts: Delete host",
1450 aliases: &["remove", "rm"],
1451 target: JumpActionTarget::Hosts,
1452 },
1453 JumpAction {
1454 key: 'c',
1455 key_str: "c",
1456 label: "Hosts: Clone host",
1457 aliases: &["duplicate", "copy"],
1458 target: JumpActionTarget::Hosts,
1459 },
1460 JumpAction {
1461 key: 'u',
1462 key_str: "u",
1463 label: "Hosts: Undo delete",
1464 aliases: &["restore"],
1465 target: JumpActionTarget::Hosts,
1466 },
1467 JumpAction {
1468 key: 't',
1469 key_str: "t",
1470 label: "Hosts: Tag host",
1471 aliases: &["label", "category"],
1472 target: JumpActionTarget::Hosts,
1473 },
1474 JumpAction {
1475 key: 'i',
1476 key_str: "i",
1477 label: "Hosts: Show all directives",
1478 aliases: &["raw", "config", "settings"],
1479 target: JumpActionTarget::Hosts,
1480 },
1481 JumpAction {
1482 key: 'y',
1483 key_str: "y",
1484 label: "Clipboard: Copy SSH command",
1485 aliases: &["yank"],
1486 target: JumpActionTarget::Hosts,
1487 },
1488 JumpAction {
1489 key: 'x',
1490 key_str: "x",
1491 label: "Clipboard: Copy config block",
1492 aliases: &["yank config"],
1493 target: JumpActionTarget::Hosts,
1494 },
1495 JumpAction {
1496 key: 'X',
1497 key_str: "X",
1498 label: "Hosts: Purge stale hosts",
1499 aliases: &["clean", "cleanup"],
1500 target: JumpActionTarget::Hosts,
1501 },
1502 JumpAction {
1503 key: 'F',
1504 key_str: "F",
1505 label: "Files: Browse remote files",
1506 aliases: &[
1507 "browse",
1508 "filesystem",
1509 "scp",
1510 "sftp",
1511 "transfer",
1512 "explorer",
1513 "open",
1514 ],
1515 target: JumpActionTarget::Hosts,
1516 },
1517 JumpAction {
1518 key: 'C',
1519 key_str: "C",
1520 label: "Containers: List containers",
1521 aliases: &["docker", "podman", "ps", "open"],
1522 target: JumpActionTarget::Hosts,
1523 },
1524 JumpAction {
1525 key: 'K',
1526 key_str: "K",
1527 label: "Keys: Manage SSH keys",
1528 aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
1529 target: JumpActionTarget::Hosts,
1530 },
1531 JumpAction {
1532 key: 'S',
1533 key_str: "S",
1534 label: "Providers: Manage cloud sync",
1535 aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
1536 target: JumpActionTarget::Hosts,
1537 },
1538 JumpAction {
1539 key: 'V',
1540 key_str: "V",
1541 label: "Vault: Sign certificate",
1542 aliases: &["hashicorp", "ssh cert", "vault ssh"],
1543 target: JumpActionTarget::Hosts,
1544 },
1545 JumpAction {
1546 key: 'I',
1547 key_str: "I",
1548 label: "Hosts: Import from known_hosts",
1549 aliases: &["known", "import"],
1550 target: JumpActionTarget::Hosts,
1551 },
1552 JumpAction {
1553 key: 'm',
1554 key_str: "m",
1555 label: "Settings: Switch theme",
1556 aliases: &["color", "appearance", "dark", "light"],
1557 target: JumpActionTarget::Hosts,
1558 },
1559 JumpAction {
1560 key: 'n',
1561 key_str: "n",
1562 label: "Help: What's new",
1563 aliases: &["changelog", "news", "release notes"],
1564 target: JumpActionTarget::Hosts,
1565 },
1566 JumpAction {
1567 key: 'r',
1568 key_str: "r",
1569 label: "Snippets: Run snippet",
1570 aliases: &["execute", "command"],
1571 target: JumpActionTarget::Hosts,
1572 },
1573 JumpAction {
1574 key: 'R',
1575 key_str: "R",
1576 label: "Snippets: Run on all visible",
1577 aliases: &["batch", "execute all"],
1578 target: JumpActionTarget::Hosts,
1579 },
1580 JumpAction {
1581 key: 'p',
1582 key_str: "p",
1583 label: "Hosts: Ping host",
1584 aliases: &["health", "check"],
1585 target: JumpActionTarget::Hosts,
1586 },
1587 JumpAction {
1588 key: 'P',
1589 key_str: "P",
1590 label: "Hosts: Ping all hosts",
1591 aliases: &["health all"],
1592 target: JumpActionTarget::Hosts,
1593 },
1594 JumpAction {
1595 key: '!',
1596 key_str: "!",
1597 label: "Hosts: Show down only",
1598 aliases: &["filter offline", "down only"],
1599 target: JumpActionTarget::Hosts,
1600 },
1601 JumpAction {
1605 key: 'T',
1606 key_str: "T",
1607 label: "Tunnels: Manage tunnels",
1608 aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
1609 target: JumpActionTarget::Hosts,
1610 },
1611 JumpAction {
1612 key: 'a',
1613 key_str: "a",
1614 label: "Tunnels: Add tunnel",
1615 aliases: &["new tunnel", "create tunnel", "forward"],
1616 target: JumpActionTarget::Tunnels,
1617 },
1618 JumpAction {
1619 key: 'e',
1620 key_str: "e",
1621 label: "Tunnels: Edit tunnel",
1622 aliases: &["modify tunnel"],
1623 target: JumpActionTarget::Tunnels,
1624 },
1625 JumpAction {
1626 key: 'd',
1627 key_str: "d",
1628 label: "Tunnels: Delete tunnel",
1629 aliases: &["remove tunnel"],
1630 target: JumpActionTarget::Tunnels,
1631 },
1632 JumpAction {
1633 key: 's',
1634 key_str: "s",
1635 label: "Tunnels: Sort",
1636 aliases: &["order tunnels"],
1637 target: JumpActionTarget::Tunnels,
1638 },
1639 JumpAction {
1640 key: 'R',
1641 key_str: "R",
1642 label: "Containers: Refresh all hosts",
1643 aliases: &["reload containers", "fetch", "rescan"],
1644 target: JumpActionTarget::Containers,
1645 },
1646 JumpAction {
1647 key: 's',
1648 key_str: "s",
1649 label: "Containers: Cycle sort",
1650 aliases: &["order containers", "sort by host", "sort by name"],
1651 target: JumpActionTarget::Containers,
1652 },
1653 JumpAction {
1654 key: 'v',
1655 key_str: "v",
1656 label: "Containers: Toggle detail panel",
1657 aliases: &["show details", "hide details", "compact view"],
1658 target: JumpActionTarget::Containers,
1659 },
1660 JumpAction {
1664 key: 'c',
1665 key_str: "c",
1666 label: "Keys: Copy public key",
1667 aliases: &["yank", "clipboard", "pubkey"],
1668 target: JumpActionTarget::Keys,
1669 },
1670 JumpAction {
1671 key: 'p',
1672 key_str: "p",
1673 label: "Keys: Push to host",
1674 aliases: &["install", "ssh-copy-id", "deploy", "upload"],
1675 target: JumpActionTarget::Keys,
1676 },
1677 JumpAction {
1678 key: 'V',
1679 key_str: "V",
1680 label: "Keys: Sign Vault SSH certificate",
1681 aliases: &["vault", "renew cert", "sign"],
1682 target: JumpActionTarget::Keys,
1683 },
1684];
1685
1686pub const PALETTE_PER_SECTION_CAP: usize = 32;
1691
1692pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
1695 if let Some((prefix, rest)) = query.split_once(':') {
1696 let scope = match prefix.trim() {
1697 "user" => Some(QueryScope::User),
1698 "host" => Some(QueryScope::Hostname),
1699 "proxy" => Some(QueryScope::ProxyJump),
1700 "vault" => Some(QueryScope::VaultSsh),
1701 "tag" => Some(QueryScope::Tag),
1702 _ => None,
1703 };
1704 if scope.is_some() {
1705 return (scope, rest.trim_start());
1706 }
1707 }
1708 (None, query)
1709}
1710
1711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1712pub enum QueryScope {
1713 User,
1714 Hostname,
1715 ProxyJump,
1716 VaultSsh,
1717 Tag,
1718}
1719
1720fn preview(s: &str, max: usize) -> String {
1722 let s = s.replace('\n', " ");
1723 let chars: Vec<char> = s.chars().collect();
1724 if chars.len() <= max {
1725 s
1726 } else {
1727 let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
1728 out.push_str("...");
1729 out
1730 }
1731}
1732
1733fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
1738 let scope = scope?;
1739 match (hit, scope) {
1740 (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
1741 (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
1742 Some(vec![&h.hostname])
1743 }
1744 (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
1745 Some(vec![&h.proxy_jump])
1746 }
1747 (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
1748 (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
1749 _ => None,
1751 }
1752}
1753
1754pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
1759 if query.is_empty() {
1760 return None;
1761 }
1762 let q = query.to_lowercase();
1763 let alias_hit = host.alias.to_lowercase().contains(&q);
1764 let hostname_hit = host.hostname.to_lowercase().contains(&q);
1765 if alias_hit || hostname_hit {
1766 return None;
1767 }
1768 if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
1769 return Some(MatchSource::User);
1770 }
1771 if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
1772 return Some(MatchSource::ProxyJump);
1773 }
1774 if let Some(role) = &host.vault_ssh {
1775 if role.to_lowercase().contains(&q) {
1776 return Some(MatchSource::VaultSsh);
1777 }
1778 }
1779 if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
1780 return Some(MatchSource::IdentityFile);
1781 }
1782 None
1783}
1784
1785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1786pub enum MatchSource {
1787 User,
1788 ProxyJump,
1789 VaultSsh,
1790 IdentityFile,
1791}
1792
1793fn kind_rank(k: SourceKind) -> u8 {
1794 match k {
1795 SourceKind::Host => 0,
1796 SourceKind::Tunnel => 1,
1797 SourceKind::Container => 2,
1798 SourceKind::Snippet => 3,
1799 SourceKind::Action => 4,
1800 }
1801}
1802
1803fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
1808 if let Some(target) = prior {
1809 if let Some(idx) = hits.iter().position(|h| &h.identity() == target) {
1810 return idx;
1811 }
1812 }
1813 fallback.min(hits.len().saturating_sub(1))
1814}
1815
1816impl JumpAction {
1817 #[cfg(test)]
1818 pub fn all() -> &'static [JumpAction] {
1819 ALL_JUMP_ACTIONS
1820 }
1821
1822 pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
1826 ALL_JUMP_ACTIONS
1827 }
1828}
1829
1830#[cfg(test)]
1831mod tests;