1use ratatui::widgets::ListState;
2
3use crate::history::ConnectionHistory;
4use crate::ssh_config::model::SshConfigFile;
5
6pub(crate) 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(crate) use reload_state::config_changed;
93pub use reload_state::{ConflictState, ReloadState};
94pub use screen::{ContainerLogsSearch, Screen, StackMember, TopPage, WhatsNewState};
95pub use search::SearchState;
96pub use snippet_state::SnippetState;
97pub use status_state::{MessageClass, StatusCenter, StatusMessage};
98pub use tag_state::{
99 BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, TagState,
100 select_display_tags,
101};
102pub use tunnel_state::{TunnelSortMode, TunnelState};
103pub use ui_state::UiSelection;
104pub use update::UpdateState;
105pub use vault::VaultState;
106
107impl Drop for App {
109 fn drop(&mut self) {
110 for (alias, mut tunnel) in self.tunnels.active.drain() {
111 if let Err(e) = tunnel.child.kill() {
112 log::debug!("[external] Failed to kill tunnel for {alias} on shutdown: {e}");
113 }
114 let _ = tunnel.child.wait();
115 }
116 if let Some(handle) = self.vault.cancel_signing_run() {
120 let _ = handle.join();
121 }
122 self.keys.push.shutdown();
126 }
127}
128
129pub struct App {
131 pub screen: Screen,
134 pub top_page: TopPage,
137 pub running: bool,
139 pub(crate) hosts_state: HostState,
141
142 pub(crate) status_center: StatusCenter,
145 pub(crate) ui: UiSelection,
147 pub(crate) search: SearchState,
149 pub(crate) reload: ReloadState,
151 pub(crate) conflict: ConflictState,
153
154 pub(crate) keys: KeysState,
156
157 pub(crate) tags: TagState,
159
160 pub(crate) forms: FormState,
162
163 pub(crate) history: ConnectionHistory,
165
166 pub(crate) providers: ProviderState,
168
169 pub(crate) ping: PingState,
171
172 pub(crate) vault: VaultState,
174
175 pub(crate) tunnels: TunnelState,
177
178 pub(crate) snippets: SnippetState,
180
181 pub(crate) update: UpdateState,
183
184 pub bw_session: Option<String>,
186
187 pub(crate) file_browser_state: FileBrowserState,
190 pub(crate) file_browser_session: Option<crate::file_browser::FileBrowserSession>,
192
193 pub(crate) container_state: ContainerState,
196 pub(crate) container_session: Option<ContainerSession>,
198 pub(crate) containers_overview: ContainersOverviewState,
200
201 pub demo_mode: bool,
203
204 pub(crate) env: std::sync::Arc<crate::runtime::env::Env>,
208
209 pub(crate) jump: Option<JumpState>,
211}
212
213impl App {
214 pub fn new(config: SshConfigFile) -> Self {
218 #[cfg(test)]
219 let env = std::sync::Arc::new(crate::runtime::env::Env::sandboxed());
220 #[cfg(not(test))]
221 let env = std::sync::Arc::new(crate::runtime::env::Env::from_process());
222 Self::with_env(config, env)
223 }
224
225 pub fn with_env(config: SshConfigFile, env: std::sync::Arc<crate::runtime::env::Env>) -> Self {
229 let hosts = config.host_entries();
230 let patterns = config.pattern_entries();
231 let display_list = Self::build_display_list_from(&config, &hosts, &patterns);
232
233 let initial_selection = display_list.iter().position(|item| {
234 matches!(
235 item,
236 HostListItem::Host { .. } | HostListItem::Pattern { .. }
237 )
238 });
239
240 let reload = ReloadState::from_config(&env, &config);
241 let hosts_state = HostState::from_config(config, hosts, patterns, display_list);
242
243 Self {
244 screen: Screen::HostList,
245 top_page: TopPage::default(),
246 running: true,
247 hosts_state,
248 status_center: StatusCenter::default(),
249 ui: UiSelection::new_with_initial_selection(initial_selection),
250 search: SearchState::default(),
251 reload,
252 conflict: ConflictState::default(),
253 keys: KeysState {
254 list: Vec::new(),
255 list_state: ratatui::widgets::ListState::default(),
256 activity: crate::key_activity::KeyActivityLog::load(env.paths()),
257 push: KeyPushState::default(),
258 },
259 tags: TagState::default(),
260 forms: FormState::default(),
261 history: ConnectionHistory::load(env.paths()),
262 providers: ProviderState::load(env.paths()),
263 ping: PingState::from_preferences(env.paths()),
264 vault: VaultState::default(),
265 tunnels: TunnelState::default(),
266 snippets: SnippetState::with_store_loaded(env.paths()),
267 update: UpdateState::with_current_hint(&env),
268 bw_session: None,
269 file_browser_state: FileBrowserState::default(),
270 file_browser_session: None,
271 container_state: ContainerState {
272 cache: crate::containers::load_container_cache(env.paths()),
273 ..ContainerState::default()
274 },
275 container_session: None,
276 containers_overview: ContainersOverviewState::default(),
277 demo_mode: false,
278 env,
279 jump: None,
280 }
281 }
282
283 pub(crate) fn env(&self) -> &crate::runtime::env::Env {
285 &self.env
286 }
287
288 pub fn record_key_use(&mut self, alias: &str, now: u64) {
294 let paths = self.env.paths().cloned();
295 crate::key_activity::record_and_flush(&mut self.keys.activity, alias, now, paths.as_ref());
296 }
297
298 pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
302 self.hosts_state
303 .list
304 .iter()
305 .map(|h| h.alias.clone())
306 .collect()
307 }
308
309 pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
315 let new_aliases: Vec<String> = self
316 .hosts_state
317 .list
318 .iter()
319 .filter(|h| !before_aliases.contains(&h.alias))
320 .map(|h| h.alias.clone())
321 .collect();
322 for alias in new_aliases {
323 self.container_state.queue_fetch(alias);
324 }
325 }
326
327 pub fn reload_hosts(&mut self) {
339 let had_pending_vault_write = self.vault.pending_config_write;
340 let mut flushed_vault_write = false;
353 if self.vault.pending_config_write && !self.is_form_open() {
354 if self.external_config_changed() {
355 self.notify_error(
356 crate::messages::vault_config_skipped_external_change().to_string(),
357 );
358 log::warn!(
359 "[config] reload_hosts: skipping deferred vault write. external config changed"
360 );
361 } else {
362 match self.hosts_state.ssh_config.write() {
363 Ok(()) => flushed_vault_write = true,
364 Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
365 }
366 }
367 }
368 self.vault.pending_config_write = false;
371 log::debug!(
372 "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
373 );
374 let had_search = self.search.query.take();
375 let selected_alias = self
376 .selected_host()
377 .map(|h| h.alias.clone())
378 .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
379
380 self.tunnels.summaries_cache.clear();
381 self.hosts_state.render_cache.invalidate();
382 self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
383 self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
384
385 {
390 let valid_aliases: std::collections::HashSet<&str> = self
391 .hosts_state
392 .list
393 .iter()
394 .map(|h| h.alias.as_str())
395 .collect();
396
397 self.vault.prune_orphans(&valid_aliases);
398
399 if self.container_state.prune_orphans(&valid_aliases) {
404 crate::containers::save_container_cache(
405 self.env().paths(),
406 self.container_state.cache(),
407 );
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 self.containers_overview
420 .prune_by_container_ids(&valid_container_ids);
421
422 if self.containers_overview.prune_orphans(&valid_aliases) {
426 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
427 self.env().paths(),
428 self.containers_overview.collapsed_hosts(),
429 ) {
430 log::warn!("[config] failed to save collapsed_hosts after prune: {e}");
431 }
432 }
433
434 self.file_browser_state.prune_orphans(&valid_aliases);
435 self.tunnels.prune_orphans(&valid_aliases);
436 self.ping.prune_orphans(&valid_aliases);
437 }
438
439 if self.hosts_state.sort_mode == SortMode::Original
440 && matches!(self.hosts_state.group_by, GroupBy::None)
441 {
442 self.hosts_state.display_list = Self::build_display_list_from(
443 &self.hosts_state.ssh_config,
444 &self.hosts_state.list,
445 &self.hosts_state.patterns,
446 );
447 } else {
448 self.apply_sort();
449 }
450
451 if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
453 self.set_screen(Screen::HostList);
454 self.forms.bulk_tag_editor = BulkTagEditorState::default();
455 }
456
457 self.hosts_state.multi_select.clear();
459
460 if let Some(query) = had_search {
462 self.search.query = Some(query);
463 self.apply_filter();
464 } else {
465 self.search.query = None;
466 self.search.filtered_indices.clear();
467 self.search.filtered_pattern_indices.clear();
468 if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
470 self.ui.list_state.select(None);
471 } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
472 matches!(
473 item,
474 HostListItem::Host { .. } | HostListItem::Pattern { .. }
475 )
476 }) {
477 let current = self.ui.list_state.selected().unwrap_or(0);
478 if current >= self.hosts_state.display_list.len()
479 || !matches!(
480 self.hosts_state.display_list.get(current),
481 Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
482 )
483 {
484 self.ui.list_state.select(Some(pos));
485 }
486 } else {
487 self.ui.list_state.select(None);
488 }
489 }
490
491 if let Some(alias) = selected_alias {
493 self.select_host_by_alias(&alias);
494 }
495
496 log::debug!(
497 "[config] reload_hosts: hosts={} patterns={} display_items={}",
498 self.hosts_state.list.len(),
499 self.hosts_state.patterns.len(),
500 self.hosts_state.display_list.len(),
501 );
502 }
503
504 pub fn refresh_cert_cache(&mut self, alias: &str) {
515 if crate::demo_flag::is_demo() {
516 return;
517 }
518 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
519 self.vault.cert_cache.remove(alias);
520 return;
521 };
522 let role_some = crate::vault_ssh::resolve_vault_role(
523 host.vault_ssh.as_deref(),
524 host.provider.as_deref(),
525 host.provider_label.as_deref(),
526 &self.providers.config,
527 )
528 .is_some();
529 if !role_some {
530 self.vault.cert_cache.remove(alias);
531 return;
532 }
533 let cert_path = match crate::vault_ssh::resolve_cert_path(
534 self.env().paths(),
535 alias,
536 &host.certificate_file,
537 ) {
538 Ok(p) => p,
539 Err(_) => {
540 self.vault.cert_cache.remove(alias);
541 return;
542 }
543 };
544 let status = crate::vault_ssh::check_cert_validity(self.env(), &cert_path);
545 let mtime = std::fs::metadata(&cert_path)
546 .ok()
547 .and_then(|m| m.modified().ok());
548 self.vault.cert_cache.insert(
549 alias.to_string(),
550 (std::time::Instant::now(), status, mtime),
551 );
552 }
553
554 #[cfg(test)]
561 pub fn sorted_provider_names(&self) -> Vec<String> {
562 self.providers.sorted_names()
563 }
564
565 pub fn is_form_open(&self) -> bool {
567 matches!(
568 self.screen,
569 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
570 )
571 }
572
573 pub fn open_jump(&mut self, mode: JumpMode) {
576 log::debug!("jump: open mode={:?}", mode);
577 let mut state = JumpState::for_mode(mode);
578 let recents_file = jump::load_recents(self.env.paths());
579 state.recents = self.resolve_recents(&recents_file);
580 self.jump = Some(state);
581 self.recompute_jump_hits();
582 }
583
584 pub(crate) fn close_jump(&mut self) {
588 self.jump = None;
589 }
590
591 fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
594 let mode = self
595 .jump
596 .as_ref()
597 .map(|p| p.mode)
598 .unwrap_or(JumpMode::Hosts);
599 let mut out = Vec::with_capacity(file.entries.len());
600 for entry in &file.entries {
601 if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
602 out.push(hit);
603 }
604 }
605 out
606 }
607
608 #[cfg(test)]
612 pub(crate) fn resolve_recent_ref_for_test(
613 &self,
614 r: &RecentRef,
615 mode: JumpMode,
616 ) -> Option<JumpHit> {
617 self.resolve_recent_ref(r, mode)
618 }
619
620 fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
621 match r.kind {
622 SourceKind::Action => {
623 let key_char = r.key.chars().next()?;
624 let actions = JumpAction::for_mode(mode);
625 actions
626 .iter()
627 .find(|a| a.key == key_char)
628 .copied()
629 .map(JumpHit::Action)
630 }
631 SourceKind::Host => {
632 let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
633 Some(JumpHit::Host(HostHit {
634 alias: host.alias.clone(),
635 hostname: host.hostname.clone(),
636 tags: host.tags.clone(),
637 provider: host.provider.clone(),
638 user: host.user.clone(),
639 identity_file: host.identity_file.clone(),
640 proxy_jump: host.proxy_jump.clone(),
641 vault_ssh: host.vault_ssh.clone(),
642 }))
643 }
644 SourceKind::Tunnel => {
645 let (alias, port_str) = r.key.split_once(':')?;
646 let port: u16 = port_str.parse().ok()?;
647 let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
648 let rule = rules.iter().find(|r| r.bind_port == port)?;
649 Some(JumpHit::Tunnel(TunnelHit {
650 alias: alias.to_string(),
651 bind_port: rule.bind_port,
652 bind_port_str: rule.bind_port.to_string(),
653 destination: rule.display(),
654 active: self.tunnels.active.contains_key(alias),
655 }))
656 }
657 SourceKind::Container => {
658 let (alias, name) = r.key.split_once('/')?;
659 let entry = self.container_state.cache.get(alias)?;
660 let info = entry.containers.iter().find(|c| c.names == name)?;
661 Some(JumpHit::Container(ContainerHit {
662 alias: alias.to_string(),
663 container_name: info.names.clone(),
664 container_id: info.id.clone(),
665 state: info.state.clone(),
666 }))
667 }
668 SourceKind::Snippet => {
669 let snippet = self.snippets.store.get(&r.key)?;
670 Some(JumpHit::Snippet(SnippetHit {
671 name: snippet.name.clone(),
672 command_preview: preview(&snippet.command, 40),
673 }))
674 }
675 }
676 }
677
678 pub fn recompute_jump_hits(&mut self) {
684 let Some(mut state) = self.jump.take() else {
685 return;
686 };
687 let prior_identity = state
691 .visible_hits()
692 .get(state.selected)
693 .map(|h| h.identity());
694
695 let candidates = self.collect_jump_candidates(state.mode);
696 if state.query.is_empty() {
697 state.hits = candidates;
698 state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
699 self.jump = Some(state);
700 return;
701 }
702
703 let (scope, effective_query) = parse_query_scope(&state.query);
708
709 use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
710 use nucleo_matcher::{Config, Matcher, Utf32Str};
711 let matcher_state = state
712 .matcher
713 .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
714 let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
715 let mut buf: Vec<char> = Vec::new();
716 let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
717 for hit in candidates {
718 let mut best: u32 = 0;
719 let scoped_haystacks = scoped_haystacks_for(&hit, scope);
723 let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
724 hs
725 } else {
726 hit.haystacks()
727 };
728 for haystack in haystacks {
729 buf.clear();
730 let chars = Utf32Str::new(haystack, &mut buf);
731 if let Some(score) = pattern.score(chars, matcher_state) {
732 best = best.max(score);
733 }
734 }
735 if let JumpHit::Action(a) = &hit {
741 let single = effective_query.chars().next();
742 if effective_query.chars().count() == 1
743 && single
744 .map(|c| c.eq_ignore_ascii_case(&a.key))
745 .unwrap_or(false)
746 {
747 let mode_match = matches!(
748 (state.mode, a.target),
749 (JumpMode::Hosts, JumpActionTarget::Hosts)
750 | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
751 | (JumpMode::Containers, JumpActionTarget::Containers)
752 | (JumpMode::Keys, JumpActionTarget::Keys)
753 );
754 let bump = if mode_match { 20_000 } else { 10_000 };
755 best = best.saturating_add(bump);
756 }
757 }
758 let floor = match &hit {
762 JumpHit::Action(_) => jump::PALETTE_ACTION_FLOOR,
763 _ => 1,
764 };
765 if best >= floor {
766 scored.push((hit, best));
767 }
768 }
769 scored.sort_by(|a, b| {
772 b.1.cmp(&a.1)
773 .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
774 });
775 let mut per_kind: [usize; 5] = [0; 5];
778 let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
779 for (hit, _) in scored {
780 let slot = kind_rank(hit.kind()) as usize;
781 if per_kind[slot] < PALETTE_PER_SECTION_CAP {
782 per_kind[slot] += 1;
783 filtered.push(hit);
784 }
785 }
786 state.hits = filtered;
787 let display = state.visible_hits();
796 let top_display = state
797 .hits
798 .first()
799 .map(|h| h.kind())
800 .and_then(|k| display.iter().position(|h| h.kind() == k))
801 .unwrap_or(0);
802 state.selected = restore_selection(&display, prior_identity.as_ref(), top_display);
803 log::debug!(
804 "jump: recompute selected={} of {} hits (top_display={})",
805 state.selected,
806 state.hits.len(),
807 top_display
808 );
809 self.jump = Some(state);
810 }
811
812 fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
813 let mut out: Vec<JumpHit> = Vec::new();
814 for h in &self.hosts_state.list {
816 out.push(JumpHit::Host(HostHit {
817 alias: h.alias.clone(),
818 hostname: h.hostname.clone(),
819 tags: h.tags.clone(),
820 provider: h.provider.clone(),
821 user: h.user.clone(),
822 identity_file: h.identity_file.clone(),
823 proxy_jump: h.proxy_jump.clone(),
824 vault_ssh: h.vault_ssh.clone(),
825 }));
826 }
827 for h in &self.hosts_state.list {
829 let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
830 for rule in rules {
831 out.push(JumpHit::Tunnel(TunnelHit {
832 alias: h.alias.clone(),
833 bind_port: rule.bind_port,
834 bind_port_str: rule.bind_port.to_string(),
835 destination: rule.display(),
836 active: self.tunnels.active.contains_key(&h.alias),
837 }));
838 }
839 }
840 for (alias, entry) in &self.container_state.cache {
843 for info in &entry.containers {
844 out.push(JumpHit::Container(ContainerHit {
845 alias: alias.clone(),
846 container_name: info.names.clone(),
847 container_id: info.id.clone(),
848 state: info.state.clone(),
849 }));
850 }
851 }
852 for snippet in &self.snippets.store.snippets {
854 out.push(JumpHit::Snippet(SnippetHit {
855 name: snippet.name.clone(),
856 command_preview: preview(&snippet.command, 40),
857 }));
858 }
859 for a in JumpAction::for_mode(mode) {
861 out.push(JumpHit::Action(*a));
862 }
863 out
864 }
865
866 pub fn record_jump_hit(&mut self, hit: &JumpHit) {
872 if self.demo_mode {
873 log::debug!("jump: record skipped (demo mode)");
874 return;
875 }
876 let paths = self.env.paths().cloned();
877 let mut file = jump::load_recents(paths.as_ref());
878 jump::touch_recent(&mut file, hit.identity());
879 if let Err(e) = jump::save_recents(&file, paths.as_ref()) {
880 log::warn!("[purple] failed to save recents: {e}");
881 }
882 }
883
884 pub(crate) fn open_file_browser(&mut self, session: crate::file_browser::FileBrowserSession) {
888 let alias = session.alias.clone();
889 self.file_browser_session = Some(session);
890 self.set_screen(Screen::FileBrowser { alias });
891 }
892
893 pub(crate) fn close_file_browser(&mut self) {
897 if let Some(fb) = self.file_browser_session.take() {
898 self.file_browser_state
899 .host_paths
900 .insert(fb.alias, (fb.local_path, fb.remote_path));
901 }
902 self.set_screen(Screen::HostList);
903 }
904
905 pub fn flush_pending_vault_write(&mut self) -> bool {
908 if !self.vault.pending_config_write || self.is_form_open() {
909 return false;
910 }
911 self.reload_hosts();
913 true
914 }
915
916 pub fn post_init(&mut self) {
920 let outcome = crate::onboarding::evaluate(self.env().paths());
921 if let Some(text) = outcome.upgrade_toast {
922 self.enqueue_sticky_toast(text);
923 }
924 self.scan_keys();
928 }
929
930 fn enqueue_sticky_toast(&mut self, text: String) {
931 log::debug!("[purple] enqueue sticky toast: {}", text);
932 let msg = StatusMessage {
933 text,
934 class: MessageClass::Success,
935 tick_count: 0,
936 sticky: true,
937 created_at: std::time::Instant::now(),
938 };
939 self.status_center.toast = Some(msg);
940 }
941
942 pub fn notify(&mut self, text: impl Into<String>) {
944 self.status_center.set_status(text, false);
945 }
946
947 pub fn notify_error(&mut self, text: impl Into<String>) {
949 self.status_center.set_status(text, true);
950 }
951
952 pub fn notify_background(&mut self, text: impl Into<String>) {
954 self.status_center.set_background_status(text, false);
955 }
956
957 pub fn notify_background_error(&mut self, text: impl Into<String>) {
959 self.status_center.set_background_status(text, true);
960 }
961
962 pub fn notify_warning(&mut self, text: impl Into<String>) {
974 let msg = StatusMessage {
975 text: text.into(),
976 class: MessageClass::Warning,
977 tick_count: 0,
978 sticky: false,
979 created_at: std::time::Instant::now(),
980 };
981 log::debug!("toast <- Warning: {}", msg.text);
982 self.status_center.push_toast(msg);
983 }
984
985 pub fn notify_progress(&mut self, text: impl Into<String>) {
987 self.status_center.set_sticky_status(text, false);
988 }
989
990 pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
992 self.status_center.set_sticky_status(text, true);
993 }
994
995 pub fn notify_info(&mut self, text: impl Into<String>) {
997 self.status_center.set_info_status(text);
998 }
999
1000 pub(crate) fn clear_status(&mut self) {
1005 self.status_center.clear_status();
1006 }
1007
1008 pub fn tick_status(&mut self) {
1015 if !self.providers.syncing.is_empty() {
1017 return;
1018 }
1019 if let Some(ref status) = self.status_center.status {
1020 if status.sticky {
1021 return;
1022 }
1023 let timeout_ms = status.timeout_ms();
1024 if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
1025 {
1026 log::debug!("footer status expired: {}", status.text);
1027 self.status_center.status = None;
1028 }
1029 }
1030 }
1031
1032 pub fn tick_toast(&mut self) {
1034 self.status_center.tick_toast();
1035 }
1036
1037 pub fn check_config_changed(&mut self) {
1041 if matches!(
1042 self.screen,
1043 Screen::AddHost
1044 | Screen::EditHost { .. }
1045 | Screen::ProviderForm { .. }
1046 | Screen::TunnelList { .. }
1047 | Screen::TunnelForm { .. }
1048 | Screen::HostDetail { .. }
1049 | Screen::SnippetPicker { .. }
1050 | Screen::SnippetForm { .. }
1051 | Screen::SnippetOutput { .. }
1052 | Screen::SnippetParamForm { .. }
1053 | Screen::FileBrowser { .. }
1054 | Screen::Containers { .. }
1055 | Screen::ConfirmDelete { .. }
1056 | Screen::ConfirmHostKeyReset { .. }
1057 | Screen::ConfirmPurgeStale { .. }
1058 | Screen::ConfirmImport { .. }
1059 | Screen::ConfirmVaultSign { .. }
1060 | Screen::TagPicker
1061 | Screen::BulkTagEditor
1062 | Screen::ThemePicker
1063 | Screen::WhatsNew(_)
1064 ) || self.tags.input.is_some()
1065 {
1066 return;
1067 }
1068 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1069 let changed = current_mtime != self.reload.last_modified
1070 || self
1071 .reload
1072 .include_mtimes
1073 .iter()
1074 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1075 || self
1076 .reload
1077 .include_dir_mtimes
1078 .iter()
1079 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
1080 if changed {
1081 log::debug!(
1082 "[config] check_config_changed: mtime drift detected on {} -> reloading",
1083 self.reload.config_path.display()
1084 );
1085 if let Ok(new_config) =
1086 SshConfigFile::parse_with_env(&self.reload.config_path, &self.env)
1087 {
1088 let before_aliases = self.snapshot_alias_set();
1089 self.hosts_state.ssh_config = new_config;
1090 self.hosts_state.undo_stack.clear();
1092 log::debug!(
1094 "[config] external config change: clearing {} ping result(s) + timestamps",
1095 self.ping.status.len()
1096 );
1097 self.ping.status.clear();
1098 self.ping.last_checked.clear();
1099 self.ping.filter_down_only = false;
1100 self.ping.checked_at = None;
1101 self.reload_hosts();
1102 self.reload.last_modified = current_mtime;
1103 self.reload.include_mtimes =
1104 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1105 self.reload.include_dir_mtimes = reload_state::snapshot_include_dir_mtimes(
1106 &self.env,
1107 &self.hosts_state.ssh_config,
1108 );
1109 let count = self.hosts_state.list.len();
1110 self.notify_background(crate::messages::config_reloaded(count));
1111 self.queue_new_aliases_since(&before_aliases);
1112 }
1113 }
1114 }
1115
1116 pub fn check_keys_changed(&mut self) {
1126 if self.demo_mode {
1127 return;
1128 }
1129 if matches!(
1130 self.screen,
1131 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
1132 ) {
1133 return;
1134 }
1135 let Some(ssh_dir) = self.env().paths().map(crate::runtime::env::Paths::ssh_dir) else {
1136 return;
1137 };
1138 let current_dir_mtime = reload_state::get_mtime(&ssh_dir);
1139 let dir_changed = current_dir_mtime != self.reload.keys_dir_mtime;
1140 let files_changed = self
1141 .reload
1142 .key_file_mtimes
1143 .iter()
1144 .any(|(path, old)| reload_state::get_mtime(path) != *old);
1145 if !dir_changed && !files_changed {
1146 return;
1147 }
1148 log::debug!(
1149 "[purple] check_keys_changed: drift detected on {} (dir={} files={}) -> rescan",
1150 ssh_dir.display(),
1151 dir_changed,
1152 files_changed,
1153 );
1154 let previous = self.keys.list.len();
1155 self.scan_keys();
1156 let after = self.keys.list.len();
1157 if let Some(sel) = self.keys.list_state.selected() {
1160 if sel >= after {
1161 let next = after.checked_sub(1);
1162 self.keys.list_state.select(next);
1163 }
1164 } else if after > 0 {
1165 self.keys.list_state.select(Some(0));
1166 }
1167 if previous != after {
1168 log::debug!(
1169 "[purple] check_keys_changed: rescan {} -> {} keys",
1170 previous,
1171 after
1172 );
1173 }
1174 }
1175
1176 pub fn external_config_changed(&self) -> bool {
1185 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1186 current_mtime != self.reload.last_modified
1187 || self
1188 .reload
1189 .include_mtimes
1190 .iter()
1191 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1192 || self
1193 .reload
1194 .include_dir_mtimes
1195 .iter()
1196 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1197 }
1198
1199 pub fn update_last_modified(&mut self) {
1201 self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
1202 self.reload.include_mtimes =
1203 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1204 self.reload.include_dir_mtimes =
1205 reload_state::snapshot_include_dir_mtimes(&self.env, &self.hosts_state.ssh_config);
1206 }
1207
1208 pub fn has_any_vault_role(&self) -> bool {
1210 for host in &self.hosts_state.list {
1211 if host.vault_ssh.is_some() {
1212 return true;
1213 }
1214 }
1215 for section in &self.providers.config.sections {
1216 if !section.vault_role.is_empty() {
1217 return true;
1218 }
1219 }
1220 false
1221 }
1222
1223 pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
1225 self.tunnels.poll()
1226 }
1227
1228 pub fn refresh_tunnel_bind_ports(&mut self) {
1233 let mut ports: Vec<(String, u16, u32)> = Vec::new();
1234 for (alias, tunnel) in &self.tunnels.active {
1235 let pid = tunnel.child.id();
1236 for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
1237 ports.push((alias.clone(), rule.bind_port, pid));
1238 }
1239 }
1240 self.tunnels.set_lsof_ports(ports);
1241 }
1242}
1243
1244pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
1246 if len == 0 {
1247 return;
1248 }
1249 let i = match state.selected() {
1250 Some(i) => {
1251 if forward {
1252 if i >= len - 1 { 0 } else { i + 1 }
1253 } else if i == 0 {
1254 len - 1
1255 } else {
1256 i - 1
1257 }
1258 }
1259 None => 0,
1260 };
1261 state.select(Some(i));
1262}
1263
1264pub(crate) fn apply_bulk_tags(
1269 hosts: &mut HostState,
1270 forms: &mut FormState,
1271) -> Result<BulkTagApplyResult, String> {
1272 if forms.bulk_tag_editor.aliases.is_empty() {
1273 return Err(crate::messages::BULK_TAG_NO_HOSTS_SELECTED.to_string());
1274 }
1275 let aliases = forms.bulk_tag_editor.aliases.clone();
1276 let rows = forms.bulk_tag_editor.rows.clone();
1277 let skipped_set: std::collections::HashSet<&str> = forms
1278 .bulk_tag_editor
1279 .skipped_included
1280 .iter()
1281 .map(|s| s.as_str())
1282 .collect();
1283
1284 let has_pending = rows.iter().any(|r| r.action != BulkTagAction::Leave);
1287 if !has_pending {
1288 return Ok(BulkTagApplyResult {
1289 skipped_included: skipped_set.len(),
1290 ..Default::default()
1291 });
1292 }
1293
1294 let mut changed_hosts: std::collections::HashSet<String> = std::collections::HashSet::new();
1295 let mut added = 0usize;
1296 let mut removed = 0usize;
1297 let mut skipped_included = 0usize;
1298 let mut undo_snapshot: Vec<(String, Vec<String>)> = Vec::new();
1302
1303 for alias in &aliases {
1304 if skipped_set.contains(alias.as_str()) {
1305 skipped_included += 1;
1306 continue;
1307 }
1308 let Some(host) = hosts.list.iter().find(|h| &h.alias == alias) else {
1309 continue;
1310 };
1311 let original_tags = host.tags.clone();
1312 let mut new_tags = original_tags.clone();
1313 let mut host_changed = false;
1314 for row in &rows {
1315 match row.action {
1316 BulkTagAction::Leave => {}
1317 BulkTagAction::AddToAll => {
1318 if !new_tags.iter().any(|t| t == &row.tag) {
1319 new_tags.push(row.tag.clone());
1320 added += 1;
1321 host_changed = true;
1322 }
1323 }
1324 BulkTagAction::RemoveFromAll => {
1325 let before = new_tags.len();
1326 new_tags.retain(|t| t != &row.tag);
1327 if new_tags.len() != before {
1328 removed += 1;
1329 host_changed = true;
1330 }
1331 }
1332 }
1333 }
1334 if host_changed {
1335 let _ = hosts.ssh_config.set_host_tags(alias, &new_tags);
1336 changed_hosts.insert(alias.clone());
1337 undo_snapshot.push((alias.clone(), original_tags));
1338 }
1339 }
1340
1341 if changed_hosts.is_empty() {
1342 return Ok(BulkTagApplyResult {
1343 skipped_included,
1344 ..Default::default()
1345 });
1346 }
1347
1348 let config_backup = hosts.ssh_config.clone();
1351 if let Err(e) = hosts.ssh_config.write() {
1352 log::error!("[purple] bulk tag apply write failed: {e}");
1353 hosts.ssh_config = config_backup;
1354 return Err(format!("Failed to save: {}", e));
1355 }
1356
1357 log::debug!(
1358 "bulk tag apply: {} hosts, +{} -{}, skipped {}",
1359 changed_hosts.len(),
1360 added,
1361 removed,
1362 skipped_included
1363 );
1364 if !undo_snapshot.is_empty() {
1367 forms.bulk_tag_undo = Some(undo_snapshot);
1368 }
1369
1370 Ok(BulkTagApplyResult {
1371 changed_hosts: changed_hosts.len(),
1372 added,
1373 removed,
1374 skipped_included,
1375 })
1376}
1377
1378pub(crate) fn bulk_tag_cycle_current(ui: &UiSelection, forms: &mut FormState) {
1381 let Some(idx) = ui.bulk_tag_editor_state.selected() else {
1382 return;
1383 };
1384 if let Some(row) = forms.bulk_tag_editor.rows.get_mut(idx) {
1385 row.action = row.action.cycle();
1386 }
1387}
1388
1389pub(crate) fn bulk_tag_commit_new_tag(ui: &mut UiSelection, forms: &mut FormState) {
1393 let Some(input) = forms.bulk_tag_editor.new_tag_input.take() else {
1394 return;
1395 };
1396 forms.bulk_tag_editor.new_tag_cursor = 0;
1397 let tag = input.trim().to_string();
1398 if tag.is_empty() {
1399 return;
1400 }
1401 if let Some(existing) = forms.bulk_tag_editor.rows.iter().position(|r| r.tag == tag) {
1402 forms.bulk_tag_editor.rows[existing].action = BulkTagAction::AddToAll;
1403 ui.bulk_tag_editor_state.select(Some(existing));
1404 return;
1405 }
1406 let row = BulkTagRow {
1407 tag,
1408 initial_count: 0,
1409 action: BulkTagAction::AddToAll,
1410 };
1411 let insert_at = forms.bulk_tag_editor.rows.len();
1412 forms.bulk_tag_editor.rows.push(row);
1413 ui.bulk_tag_editor_state.select(Some(insert_at));
1414}
1415
1416pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
1418 if len == 0 {
1419 return;
1420 }
1421 let current = state.selected().unwrap_or(0);
1422 let next = (current + page_size).min(len - 1);
1423 state.select(Some(next));
1424}
1425
1426pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
1428 if len == 0 {
1429 return;
1430 }
1431 let current = state.selected().unwrap_or(0);
1432 let prev = current.saturating_sub(page_size);
1433 state.select(Some(prev));
1434}
1435
1436pub use jump::{
1440 ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, JumpMode, JumpState, RecentRef,
1441 RecentsFile, SnippetHit, SourceKind, TunnelHit,
1442};
1443
1444#[cfg(test)]
1448pub type PaletteCommand = JumpAction;
1449
1450static ALL_JUMP_ACTIONS: &[JumpAction] = &[
1457 JumpAction {
1458 key: 'a',
1459 key_str: "a",
1460 label: "Hosts: Add host",
1461 aliases: &["new", "create"],
1462 target: JumpActionTarget::Hosts,
1463 },
1464 JumpAction {
1465 key: 'A',
1466 key_str: "A",
1467 label: "Hosts: Add pattern",
1468 aliases: &["new pattern", "wildcard"],
1469 target: JumpActionTarget::Hosts,
1470 },
1471 JumpAction {
1472 key: 'e',
1473 key_str: "e",
1474 label: "Hosts: Edit host",
1475 aliases: &["modify", "change"],
1476 target: JumpActionTarget::Hosts,
1477 },
1478 JumpAction {
1479 key: 'd',
1480 key_str: "d",
1481 label: "Hosts: Delete host",
1482 aliases: &["remove", "rm"],
1483 target: JumpActionTarget::Hosts,
1484 },
1485 JumpAction {
1486 key: 'c',
1487 key_str: "c",
1488 label: "Hosts: Clone host",
1489 aliases: &["duplicate", "copy"],
1490 target: JumpActionTarget::Hosts,
1491 },
1492 JumpAction {
1493 key: 'u',
1494 key_str: "u",
1495 label: "Hosts: Undo delete",
1496 aliases: &["restore"],
1497 target: JumpActionTarget::Hosts,
1498 },
1499 JumpAction {
1500 key: 't',
1501 key_str: "t",
1502 label: "Hosts: Tag host",
1503 aliases: &["label", "category"],
1504 target: JumpActionTarget::Hosts,
1505 },
1506 JumpAction {
1507 key: 'i',
1508 key_str: "i",
1509 label: "Hosts: Show all directives",
1510 aliases: &["raw", "config", "settings"],
1511 target: JumpActionTarget::Hosts,
1512 },
1513 JumpAction {
1514 key: 'y',
1515 key_str: "y",
1516 label: "Clipboard: Copy SSH command",
1517 aliases: &["yank"],
1518 target: JumpActionTarget::Hosts,
1519 },
1520 JumpAction {
1521 key: 'x',
1522 key_str: "x",
1523 label: "Clipboard: Copy config block",
1524 aliases: &["yank config"],
1525 target: JumpActionTarget::Hosts,
1526 },
1527 JumpAction {
1528 key: 'X',
1529 key_str: "X",
1530 label: "Hosts: Purge stale hosts",
1531 aliases: &["clean", "cleanup"],
1532 target: JumpActionTarget::Hosts,
1533 },
1534 JumpAction {
1535 key: 'F',
1536 key_str: "F",
1537 label: "Files: Browse remote files",
1538 aliases: &[
1539 "browse",
1540 "filesystem",
1541 "scp",
1542 "sftp",
1543 "transfer",
1544 "explorer",
1545 "open",
1546 ],
1547 target: JumpActionTarget::Hosts,
1548 },
1549 JumpAction {
1550 key: 'C',
1551 key_str: "C",
1552 label: "Containers: List containers",
1553 aliases: &["docker", "podman", "ps", "open"],
1554 target: JumpActionTarget::Hosts,
1555 },
1556 JumpAction {
1557 key: 'K',
1558 key_str: "K",
1559 label: "Keys: Manage SSH keys",
1560 aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
1561 target: JumpActionTarget::Hosts,
1562 },
1563 JumpAction {
1564 key: 'S',
1565 key_str: "S",
1566 label: "Providers: Manage cloud sync",
1567 aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
1568 target: JumpActionTarget::Hosts,
1569 },
1570 JumpAction {
1571 key: 'V',
1572 key_str: "V",
1573 label: "Vault: Sign certificate",
1574 aliases: &["hashicorp", "ssh cert", "vault ssh"],
1575 target: JumpActionTarget::Hosts,
1576 },
1577 JumpAction {
1578 key: 'I',
1579 key_str: "I",
1580 label: "Hosts: Import from known_hosts",
1581 aliases: &["known", "import"],
1582 target: JumpActionTarget::Hosts,
1583 },
1584 JumpAction {
1585 key: 'm',
1586 key_str: "m",
1587 label: "Settings: Switch theme",
1588 aliases: &["color", "appearance", "dark", "light"],
1589 target: JumpActionTarget::Hosts,
1590 },
1591 JumpAction {
1592 key: 'n',
1593 key_str: "n",
1594 label: "Help: What's new",
1595 aliases: &["changelog", "news", "release notes"],
1596 target: JumpActionTarget::Hosts,
1597 },
1598 JumpAction {
1599 key: 'r',
1600 key_str: "r",
1601 label: "Snippets: Run snippet",
1602 aliases: &["execute", "command"],
1603 target: JumpActionTarget::Hosts,
1604 },
1605 JumpAction {
1606 key: 'R',
1607 key_str: "R",
1608 label: "Snippets: Run on all visible",
1609 aliases: &["batch", "execute all"],
1610 target: JumpActionTarget::Hosts,
1611 },
1612 JumpAction {
1613 key: 'p',
1614 key_str: "p",
1615 label: "Hosts: Ping host",
1616 aliases: &["health", "check"],
1617 target: JumpActionTarget::Hosts,
1618 },
1619 JumpAction {
1620 key: 'P',
1621 key_str: "P",
1622 label: "Hosts: Ping all hosts",
1623 aliases: &["health all"],
1624 target: JumpActionTarget::Hosts,
1625 },
1626 JumpAction {
1627 key: '!',
1628 key_str: "!",
1629 label: "Hosts: Show down only",
1630 aliases: &["filter offline", "down only"],
1631 target: JumpActionTarget::Hosts,
1632 },
1633 JumpAction {
1637 key: 'T',
1638 key_str: "T",
1639 label: "Tunnels: Manage tunnels",
1640 aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
1641 target: JumpActionTarget::Hosts,
1642 },
1643 JumpAction {
1644 key: 'a',
1645 key_str: "a",
1646 label: "Tunnels: Add tunnel",
1647 aliases: &["new tunnel", "create tunnel", "forward"],
1648 target: JumpActionTarget::Tunnels,
1649 },
1650 JumpAction {
1651 key: 'e',
1652 key_str: "e",
1653 label: "Tunnels: Edit tunnel",
1654 aliases: &["modify tunnel"],
1655 target: JumpActionTarget::Tunnels,
1656 },
1657 JumpAction {
1658 key: 'd',
1659 key_str: "d",
1660 label: "Tunnels: Delete tunnel",
1661 aliases: &["remove tunnel"],
1662 target: JumpActionTarget::Tunnels,
1663 },
1664 JumpAction {
1665 key: 's',
1666 key_str: "s",
1667 label: "Tunnels: Sort",
1668 aliases: &["order tunnels"],
1669 target: JumpActionTarget::Tunnels,
1670 },
1671 JumpAction {
1672 key: 'R',
1673 key_str: "R",
1674 label: "Containers: Refresh all hosts",
1675 aliases: &["reload containers", "fetch", "rescan"],
1676 target: JumpActionTarget::Containers,
1677 },
1678 JumpAction {
1679 key: 's',
1680 key_str: "s",
1681 label: "Containers: Cycle sort",
1682 aliases: &["order containers", "sort by host", "sort by name"],
1683 target: JumpActionTarget::Containers,
1684 },
1685 JumpAction {
1686 key: 'v',
1687 key_str: "v",
1688 label: "Containers: Toggle detail panel",
1689 aliases: &["show details", "hide details", "compact view"],
1690 target: JumpActionTarget::Containers,
1691 },
1692 JumpAction {
1696 key: 'c',
1697 key_str: "c",
1698 label: "Keys: Copy public key",
1699 aliases: &["yank", "clipboard", "pubkey"],
1700 target: JumpActionTarget::Keys,
1701 },
1702 JumpAction {
1703 key: 'p',
1704 key_str: "p",
1705 label: "Keys: Push to host",
1706 aliases: &["install", "ssh-copy-id", "deploy", "upload"],
1707 target: JumpActionTarget::Keys,
1708 },
1709 JumpAction {
1710 key: 'V',
1711 key_str: "V",
1712 label: "Keys: Sign Vault SSH certificate",
1713 aliases: &["vault", "renew cert", "sign"],
1714 target: JumpActionTarget::Keys,
1715 },
1716];
1717
1718pub const PALETTE_PER_SECTION_CAP: usize = 32;
1723
1724pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
1727 if let Some((prefix, rest)) = query.split_once(':') {
1728 let scope = match prefix.trim() {
1729 "user" => Some(QueryScope::User),
1730 "host" => Some(QueryScope::Hostname),
1731 "proxy" => Some(QueryScope::ProxyJump),
1732 "vault" => Some(QueryScope::VaultSsh),
1733 "tag" => Some(QueryScope::Tag),
1734 _ => None,
1735 };
1736 if scope.is_some() {
1737 return (scope, rest.trim_start());
1738 }
1739 }
1740 (None, query)
1741}
1742
1743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1744pub enum QueryScope {
1745 User,
1746 Hostname,
1747 ProxyJump,
1748 VaultSsh,
1749 Tag,
1750}
1751
1752fn preview(s: &str, max: usize) -> String {
1754 let s = s.replace('\n', " ");
1755 let chars: Vec<char> = s.chars().collect();
1756 if chars.len() <= max {
1757 s
1758 } else {
1759 let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
1760 out.push_str("...");
1761 out
1762 }
1763}
1764
1765fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
1770 let scope = scope?;
1771 match (hit, scope) {
1772 (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
1773 (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
1774 Some(vec![&h.hostname])
1775 }
1776 (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
1777 Some(vec![&h.proxy_jump])
1778 }
1779 (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
1780 (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
1781 _ => None,
1783 }
1784}
1785
1786pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
1791 if query.is_empty() {
1792 return None;
1793 }
1794 let q = query.to_lowercase();
1795 let alias_hit = host.alias.to_lowercase().contains(&q);
1796 let hostname_hit = host.hostname.to_lowercase().contains(&q);
1797 if alias_hit || hostname_hit {
1798 return None;
1799 }
1800 if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
1801 return Some(MatchSource::User);
1802 }
1803 if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
1804 return Some(MatchSource::ProxyJump);
1805 }
1806 if let Some(role) = &host.vault_ssh {
1807 if role.to_lowercase().contains(&q) {
1808 return Some(MatchSource::VaultSsh);
1809 }
1810 }
1811 if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
1812 return Some(MatchSource::IdentityFile);
1813 }
1814 None
1815}
1816
1817#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1818pub enum MatchSource {
1819 User,
1820 ProxyJump,
1821 VaultSsh,
1822 IdentityFile,
1823}
1824
1825fn kind_rank(k: SourceKind) -> u8 {
1826 match k {
1827 SourceKind::Host => 0,
1828 SourceKind::Tunnel => 1,
1829 SourceKind::Container => 2,
1830 SourceKind::Snippet => 3,
1831 SourceKind::Action => 4,
1832 }
1833}
1834
1835fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
1840 if let Some(target) = prior {
1841 if let Some(idx) = hits.iter().position(|h| &h.identity() == target) {
1842 return idx;
1843 }
1844 }
1845 fallback.min(hits.len().saturating_sub(1))
1846}
1847
1848impl JumpAction {
1849 #[cfg(test)]
1850 pub fn all() -> &'static [JumpAction] {
1851 ALL_JUMP_ACTIONS
1852 }
1853
1854 pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
1858 ALL_JUMP_ACTIONS
1859 }
1860}
1861
1862#[cfg(test)]
1863mod tests;