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