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(&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(),
257 push: KeyPushState::default(),
258 },
259 tags: TagState::default(),
260 forms: FormState::default(),
261 history: ConnectionHistory::load(),
262 providers: ProviderState::load(),
263 ping: PingState::from_preferences(env.paths()),
264 vault: VaultState::default(),
265 tunnels: TunnelState::default(),
266 snippets: SnippetState::with_store_loaded(),
267 update: UpdateState::with_current_hint(),
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 crate::key_activity::record_and_flush(&mut self.keys.activity, alias, now);
295 }
296
297 pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
301 self.hosts_state
302 .list
303 .iter()
304 .map(|h| h.alias.clone())
305 .collect()
306 }
307
308 pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
314 let new_aliases: Vec<String> = self
315 .hosts_state
316 .list
317 .iter()
318 .filter(|h| !before_aliases.contains(&h.alias))
319 .map(|h| h.alias.clone())
320 .collect();
321 for alias in new_aliases {
322 self.container_state.queue_fetch(alias);
323 }
324 }
325
326 pub fn reload_hosts(&mut self) {
338 let had_pending_vault_write = self.vault.pending_config_write;
339 let mut flushed_vault_write = false;
352 if self.vault.pending_config_write && !self.is_form_open() {
353 if self.external_config_changed() {
354 self.notify_error(
355 crate::messages::vault_config_skipped_external_change().to_string(),
356 );
357 log::warn!(
358 "[config] reload_hosts: skipping deferred vault write. external config changed"
359 );
360 } else {
361 match self.hosts_state.ssh_config.write() {
362 Ok(()) => flushed_vault_write = true,
363 Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
364 }
365 }
366 }
367 self.vault.pending_config_write = false;
370 log::debug!(
371 "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
372 );
373 let had_search = self.search.query.take();
374 let selected_alias = self
375 .selected_host()
376 .map(|h| h.alias.clone())
377 .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
378
379 self.tunnels.summaries_cache.clear();
380 self.hosts_state.render_cache.invalidate();
381 self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
382 self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
383
384 {
389 let valid_aliases: std::collections::HashSet<&str> = self
390 .hosts_state
391 .list
392 .iter()
393 .map(|h| h.alias.as_str())
394 .collect();
395
396 self.vault.prune_orphans(&valid_aliases);
397
398 if self.container_state.prune_orphans(&valid_aliases) {
403 crate::containers::save_container_cache(
404 self.env().paths(),
405 self.container_state.cache(),
406 );
407 }
408
409 let valid_container_ids: std::collections::HashSet<String> = self
413 .container_state
414 .cache()
415 .values()
416 .flat_map(|e| e.containers.iter().map(|c| c.id.clone()))
417 .collect();
418 self.containers_overview
419 .prune_by_container_ids(&valid_container_ids);
420
421 if self.containers_overview.prune_orphans(&valid_aliases) {
425 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
426 self.env().paths(),
427 self.containers_overview.collapsed_hosts(),
428 ) {
429 log::warn!("[config] failed to save collapsed_hosts after prune: {e}");
430 }
431 }
432
433 self.file_browser_state.prune_orphans(&valid_aliases);
434 self.tunnels.prune_orphans(&valid_aliases);
435 self.ping.prune_orphans(&valid_aliases);
436 }
437
438 if self.hosts_state.sort_mode == SortMode::Original
439 && matches!(self.hosts_state.group_by, GroupBy::None)
440 {
441 self.hosts_state.display_list = Self::build_display_list_from(
442 &self.hosts_state.ssh_config,
443 &self.hosts_state.list,
444 &self.hosts_state.patterns,
445 );
446 } else {
447 self.apply_sort();
448 }
449
450 if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
452 self.set_screen(Screen::HostList);
453 self.forms.bulk_tag_editor = BulkTagEditorState::default();
454 }
455
456 self.hosts_state.multi_select.clear();
458
459 if let Some(query) = had_search {
461 self.search.query = Some(query);
462 self.apply_filter();
463 } else {
464 self.search.query = None;
465 self.search.filtered_indices.clear();
466 self.search.filtered_pattern_indices.clear();
467 if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
469 self.ui.list_state.select(None);
470 } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
471 matches!(
472 item,
473 HostListItem::Host { .. } | HostListItem::Pattern { .. }
474 )
475 }) {
476 let current = self.ui.list_state.selected().unwrap_or(0);
477 if current >= self.hosts_state.display_list.len()
478 || !matches!(
479 self.hosts_state.display_list.get(current),
480 Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
481 )
482 {
483 self.ui.list_state.select(Some(pos));
484 }
485 } else {
486 self.ui.list_state.select(None);
487 }
488 }
489
490 if let Some(alias) = selected_alias {
492 self.select_host_by_alias(&alias);
493 }
494
495 log::debug!(
496 "[config] reload_hosts: hosts={} patterns={} display_items={}",
497 self.hosts_state.list.len(),
498 self.hosts_state.patterns.len(),
499 self.hosts_state.display_list.len(),
500 );
501 }
502
503 pub fn refresh_cert_cache(&mut self, alias: &str) {
514 if crate::demo_flag::is_demo() {
515 return;
516 }
517 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
518 self.vault.cert_cache.remove(alias);
519 return;
520 };
521 let role_some = crate::vault_ssh::resolve_vault_role(
522 host.vault_ssh.as_deref(),
523 host.provider.as_deref(),
524 host.provider_label.as_deref(),
525 &self.providers.config,
526 )
527 .is_some();
528 if !role_some {
529 self.vault.cert_cache.remove(alias);
530 return;
531 }
532 let cert_path = match crate::vault_ssh::resolve_cert_path(
533 self.env().paths(),
534 alias,
535 &host.certificate_file,
536 ) {
537 Ok(p) => p,
538 Err(_) => {
539 self.vault.cert_cache.remove(alias);
540 return;
541 }
542 };
543 let status = crate::vault_ssh::check_cert_validity(self.env(), &cert_path);
544 let mtime = std::fs::metadata(&cert_path)
545 .ok()
546 .and_then(|m| m.modified().ok());
547 self.vault.cert_cache.insert(
548 alias.to_string(),
549 (std::time::Instant::now(), status, mtime),
550 );
551 }
552
553 #[cfg(test)]
560 pub fn sorted_provider_names(&self) -> Vec<String> {
561 self.providers.sorted_names()
562 }
563
564 pub fn is_form_open(&self) -> bool {
566 matches!(
567 self.screen,
568 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
569 )
570 }
571
572 pub fn open_jump(&mut self, mode: JumpMode) {
575 log::debug!("jump: open mode={:?}", mode);
576 let mut state = JumpState::for_mode(mode);
577 let recents_file = jump::load_recents();
578 state.recents = self.resolve_recents(&recents_file);
579 self.jump = Some(state);
580 self.recompute_jump_hits();
581 }
582
583 pub(crate) fn close_jump(&mut self) {
587 self.jump = None;
588 }
589
590 fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
593 let mode = self
594 .jump
595 .as_ref()
596 .map(|p| p.mode)
597 .unwrap_or(JumpMode::Hosts);
598 let mut out = Vec::with_capacity(file.entries.len());
599 for entry in &file.entries {
600 if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
601 out.push(hit);
602 }
603 }
604 out
605 }
606
607 #[cfg(test)]
611 pub(crate) fn resolve_recent_ref_for_test(
612 &self,
613 r: &RecentRef,
614 mode: JumpMode,
615 ) -> Option<JumpHit> {
616 self.resolve_recent_ref(r, mode)
617 }
618
619 fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
620 match r.kind {
621 SourceKind::Action => {
622 let key_char = r.key.chars().next()?;
623 let actions = JumpAction::for_mode(mode);
624 actions
625 .iter()
626 .find(|a| a.key == key_char)
627 .copied()
628 .map(JumpHit::Action)
629 }
630 SourceKind::Host => {
631 let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
632 Some(JumpHit::Host(HostHit {
633 alias: host.alias.clone(),
634 hostname: host.hostname.clone(),
635 tags: host.tags.clone(),
636 provider: host.provider.clone(),
637 user: host.user.clone(),
638 identity_file: host.identity_file.clone(),
639 proxy_jump: host.proxy_jump.clone(),
640 vault_ssh: host.vault_ssh.clone(),
641 }))
642 }
643 SourceKind::Tunnel => {
644 let (alias, port_str) = r.key.split_once(':')?;
645 let port: u16 = port_str.parse().ok()?;
646 let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
647 let rule = rules.iter().find(|r| r.bind_port == port)?;
648 Some(JumpHit::Tunnel(TunnelHit {
649 alias: alias.to_string(),
650 bind_port: rule.bind_port,
651 bind_port_str: rule.bind_port.to_string(),
652 destination: rule.display(),
653 active: self.tunnels.active.contains_key(alias),
654 }))
655 }
656 SourceKind::Container => {
657 let (alias, name) = r.key.split_once('/')?;
658 let entry = self.container_state.cache.get(alias)?;
659 let info = entry.containers.iter().find(|c| c.names == name)?;
660 Some(JumpHit::Container(ContainerHit {
661 alias: alias.to_string(),
662 container_name: info.names.clone(),
663 container_id: info.id.clone(),
664 state: info.state.clone(),
665 }))
666 }
667 SourceKind::Snippet => {
668 let snippet = self.snippets.store.get(&r.key)?;
669 Some(JumpHit::Snippet(SnippetHit {
670 name: snippet.name.clone(),
671 command_preview: preview(&snippet.command, 40),
672 }))
673 }
674 }
675 }
676
677 pub fn recompute_jump_hits(&mut self) {
683 let Some(mut state) = self.jump.take() else {
684 return;
685 };
686 let prior_identity = state
690 .visible_hits()
691 .get(state.selected)
692 .map(|h| h.identity());
693
694 let candidates = self.collect_jump_candidates(state.mode);
695 if state.query.is_empty() {
696 state.hits = candidates;
697 state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
698 self.jump = Some(state);
699 return;
700 }
701
702 let (scope, effective_query) = parse_query_scope(&state.query);
707
708 use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
709 use nucleo_matcher::{Config, Matcher, Utf32Str};
710 let matcher_state = state
711 .matcher
712 .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
713 let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
714 let mut buf: Vec<char> = Vec::new();
715 let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
716 for hit in candidates {
717 let mut best: u32 = 0;
718 let scoped_haystacks = scoped_haystacks_for(&hit, scope);
722 let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
723 hs
724 } else {
725 hit.haystacks()
726 };
727 for haystack in haystacks {
728 buf.clear();
729 let chars = Utf32Str::new(haystack, &mut buf);
730 if let Some(score) = pattern.score(chars, matcher_state) {
731 best = best.max(score);
732 }
733 }
734 if let JumpHit::Action(a) = &hit {
740 let single = effective_query.chars().next();
741 if effective_query.chars().count() == 1
742 && single
743 .map(|c| c.eq_ignore_ascii_case(&a.key))
744 .unwrap_or(false)
745 {
746 let mode_match = matches!(
747 (state.mode, a.target),
748 (JumpMode::Hosts, JumpActionTarget::Hosts)
749 | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
750 | (JumpMode::Containers, JumpActionTarget::Containers)
751 | (JumpMode::Keys, JumpActionTarget::Keys)
752 );
753 let bump = if mode_match { 20_000 } else { 10_000 };
754 best = best.saturating_add(bump);
755 }
756 }
757 let floor = match &hit {
761 JumpHit::Action(_) => jump::PALETTE_ACTION_FLOOR,
762 _ => 1,
763 };
764 if best >= floor {
765 scored.push((hit, best));
766 }
767 }
768 scored.sort_by(|a, b| {
771 b.1.cmp(&a.1)
772 .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
773 });
774 let mut per_kind: [usize; 5] = [0; 5];
777 let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
778 for (hit, _) in scored {
779 let slot = kind_rank(hit.kind()) as usize;
780 if per_kind[slot] < PALETTE_PER_SECTION_CAP {
781 per_kind[slot] += 1;
782 filtered.push(hit);
783 }
784 }
785 state.hits = filtered;
786 let display = state.visible_hits();
795 let top_display = state
796 .hits
797 .first()
798 .map(|h| h.kind())
799 .and_then(|k| display.iter().position(|h| h.kind() == k))
800 .unwrap_or(0);
801 state.selected = restore_selection(&display, prior_identity.as_ref(), top_display);
802 log::debug!(
803 "jump: recompute selected={} of {} hits (top_display={})",
804 state.selected,
805 state.hits.len(),
806 top_display
807 );
808 self.jump = Some(state);
809 }
810
811 fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
812 let mut out: Vec<JumpHit> = Vec::new();
813 for h in &self.hosts_state.list {
815 out.push(JumpHit::Host(HostHit {
816 alias: h.alias.clone(),
817 hostname: h.hostname.clone(),
818 tags: h.tags.clone(),
819 provider: h.provider.clone(),
820 user: h.user.clone(),
821 identity_file: h.identity_file.clone(),
822 proxy_jump: h.proxy_jump.clone(),
823 vault_ssh: h.vault_ssh.clone(),
824 }));
825 }
826 for h in &self.hosts_state.list {
828 let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
829 for rule in rules {
830 out.push(JumpHit::Tunnel(TunnelHit {
831 alias: h.alias.clone(),
832 bind_port: rule.bind_port,
833 bind_port_str: rule.bind_port.to_string(),
834 destination: rule.display(),
835 active: self.tunnels.active.contains_key(&h.alias),
836 }));
837 }
838 }
839 for (alias, entry) in &self.container_state.cache {
842 for info in &entry.containers {
843 out.push(JumpHit::Container(ContainerHit {
844 alias: alias.clone(),
845 container_name: info.names.clone(),
846 container_id: info.id.clone(),
847 state: info.state.clone(),
848 }));
849 }
850 }
851 for snippet in &self.snippets.store.snippets {
853 out.push(JumpHit::Snippet(SnippetHit {
854 name: snippet.name.clone(),
855 command_preview: preview(&snippet.command, 40),
856 }));
857 }
858 for a in JumpAction::for_mode(mode) {
860 out.push(JumpHit::Action(*a));
861 }
862 out
863 }
864
865 pub fn record_jump_hit(&mut self, hit: &JumpHit) {
871 if self.demo_mode {
872 log::debug!("jump: record skipped (demo mode)");
873 return;
874 }
875 let mut file = jump::load_recents();
876 jump::touch_recent(&mut file, hit.identity());
877 if let Err(e) = jump::save_recents(&file) {
878 log::warn!("[purple] failed to save recents: {e}");
879 }
880 }
881
882 pub(crate) fn open_file_browser(&mut self, session: crate::file_browser::FileBrowserSession) {
886 let alias = session.alias.clone();
887 self.file_browser_session = Some(session);
888 self.set_screen(Screen::FileBrowser { alias });
889 }
890
891 pub(crate) fn close_file_browser(&mut self) {
895 if let Some(fb) = self.file_browser_session.take() {
896 self.file_browser_state
897 .host_paths
898 .insert(fb.alias, (fb.local_path, fb.remote_path));
899 }
900 self.set_screen(Screen::HostList);
901 }
902
903 pub fn flush_pending_vault_write(&mut self) -> bool {
906 if !self.vault.pending_config_write || self.is_form_open() {
907 return false;
908 }
909 self.reload_hosts();
911 true
912 }
913
914 pub fn post_init(&mut self) {
918 let outcome = crate::onboarding::evaluate(self.env().paths());
919 if let Some(text) = outcome.upgrade_toast {
920 self.enqueue_sticky_toast(text);
921 }
922 self.scan_keys();
926 }
927
928 fn enqueue_sticky_toast(&mut self, text: String) {
929 log::debug!("[purple] enqueue sticky toast: {}", text);
930 let msg = StatusMessage {
931 text,
932 class: MessageClass::Success,
933 tick_count: 0,
934 sticky: true,
935 created_at: std::time::Instant::now(),
936 };
937 self.status_center.toast = Some(msg);
938 }
939
940 pub fn notify(&mut self, text: impl Into<String>) {
942 self.status_center.set_status(text, false);
943 }
944
945 pub fn notify_error(&mut self, text: impl Into<String>) {
947 self.status_center.set_status(text, true);
948 }
949
950 pub fn notify_background(&mut self, text: impl Into<String>) {
952 self.status_center.set_background_status(text, false);
953 }
954
955 pub fn notify_background_error(&mut self, text: impl Into<String>) {
957 self.status_center.set_background_status(text, true);
958 }
959
960 pub fn notify_warning(&mut self, text: impl Into<String>) {
972 let msg = StatusMessage {
973 text: text.into(),
974 class: MessageClass::Warning,
975 tick_count: 0,
976 sticky: false,
977 created_at: std::time::Instant::now(),
978 };
979 log::debug!("toast <- Warning: {}", msg.text);
980 self.status_center.push_toast(msg);
981 }
982
983 pub fn notify_progress(&mut self, text: impl Into<String>) {
985 self.status_center.set_sticky_status(text, false);
986 }
987
988 pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
990 self.status_center.set_sticky_status(text, true);
991 }
992
993 pub fn notify_info(&mut self, text: impl Into<String>) {
995 self.status_center.set_info_status(text);
996 }
997
998 pub(crate) fn clear_status(&mut self) {
1003 self.status_center.clear_status();
1004 }
1005
1006 pub fn tick_status(&mut self) {
1013 if !self.providers.syncing.is_empty() {
1015 return;
1016 }
1017 if let Some(ref status) = self.status_center.status {
1018 if status.sticky {
1019 return;
1020 }
1021 let timeout_ms = status.timeout_ms();
1022 if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
1023 {
1024 log::debug!("footer status expired: {}", status.text);
1025 self.status_center.status = None;
1026 }
1027 }
1028 }
1029
1030 pub fn tick_toast(&mut self) {
1032 self.status_center.tick_toast();
1033 }
1034
1035 pub fn check_config_changed(&mut self) {
1039 if matches!(
1040 self.screen,
1041 Screen::AddHost
1042 | Screen::EditHost { .. }
1043 | Screen::ProviderForm { .. }
1044 | Screen::TunnelList { .. }
1045 | Screen::TunnelForm { .. }
1046 | Screen::HostDetail { .. }
1047 | Screen::SnippetPicker { .. }
1048 | Screen::SnippetForm { .. }
1049 | Screen::SnippetOutput { .. }
1050 | Screen::SnippetParamForm { .. }
1051 | Screen::FileBrowser { .. }
1052 | Screen::Containers { .. }
1053 | Screen::ConfirmDelete { .. }
1054 | Screen::ConfirmHostKeyReset { .. }
1055 | Screen::ConfirmPurgeStale { .. }
1056 | Screen::ConfirmImport { .. }
1057 | Screen::ConfirmVaultSign { .. }
1058 | Screen::TagPicker
1059 | Screen::BulkTagEditor
1060 | Screen::ThemePicker
1061 | Screen::WhatsNew(_)
1062 ) || self.tags.input.is_some()
1063 {
1064 return;
1065 }
1066 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1067 let changed = current_mtime != self.reload.last_modified
1068 || self
1069 .reload
1070 .include_mtimes
1071 .iter()
1072 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1073 || self
1074 .reload
1075 .include_dir_mtimes
1076 .iter()
1077 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
1078 if changed {
1079 log::debug!(
1080 "[config] check_config_changed: mtime drift detected on {} -> reloading",
1081 self.reload.config_path.display()
1082 );
1083 if let Ok(new_config) = SshConfigFile::parse(&self.reload.config_path) {
1084 let before_aliases = self.snapshot_alias_set();
1085 self.hosts_state.ssh_config = new_config;
1086 self.hosts_state.undo_stack.clear();
1088 log::debug!(
1090 "[config] external config change: clearing {} ping result(s) + timestamps",
1091 self.ping.status.len()
1092 );
1093 self.ping.status.clear();
1094 self.ping.last_checked.clear();
1095 self.ping.filter_down_only = false;
1096 self.ping.checked_at = None;
1097 self.reload_hosts();
1098 self.reload.last_modified = current_mtime;
1099 self.reload.include_mtimes =
1100 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1101 self.reload.include_dir_mtimes =
1102 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1103 let count = self.hosts_state.list.len();
1104 self.notify_background(crate::messages::config_reloaded(count));
1105 self.queue_new_aliases_since(&before_aliases);
1106 }
1107 }
1108 }
1109
1110 pub fn check_keys_changed(&mut self) {
1120 if self.demo_mode {
1121 return;
1122 }
1123 if matches!(
1124 self.screen,
1125 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
1126 ) {
1127 return;
1128 }
1129 let Some(ssh_dir) = self.env().paths().map(crate::runtime::env::Paths::ssh_dir) else {
1130 return;
1131 };
1132 let current_dir_mtime = reload_state::get_mtime(&ssh_dir);
1133 let dir_changed = current_dir_mtime != self.reload.keys_dir_mtime;
1134 let files_changed = self
1135 .reload
1136 .key_file_mtimes
1137 .iter()
1138 .any(|(path, old)| reload_state::get_mtime(path) != *old);
1139 if !dir_changed && !files_changed {
1140 return;
1141 }
1142 log::debug!(
1143 "[purple] check_keys_changed: drift detected on {} (dir={} files={}) -> rescan",
1144 ssh_dir.display(),
1145 dir_changed,
1146 files_changed,
1147 );
1148 let previous = self.keys.list.len();
1149 self.scan_keys();
1150 let after = self.keys.list.len();
1151 if let Some(sel) = self.keys.list_state.selected() {
1154 if sel >= after {
1155 let next = after.checked_sub(1);
1156 self.keys.list_state.select(next);
1157 }
1158 } else if after > 0 {
1159 self.keys.list_state.select(Some(0));
1160 }
1161 if previous != after {
1162 log::debug!(
1163 "[purple] check_keys_changed: rescan {} -> {} keys",
1164 previous,
1165 after
1166 );
1167 }
1168 }
1169
1170 pub fn external_config_changed(&self) -> bool {
1179 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1180 current_mtime != self.reload.last_modified
1181 || self
1182 .reload
1183 .include_mtimes
1184 .iter()
1185 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1186 || self
1187 .reload
1188 .include_dir_mtimes
1189 .iter()
1190 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1191 }
1192
1193 pub fn update_last_modified(&mut self) {
1195 self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
1196 self.reload.include_mtimes =
1197 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1198 self.reload.include_dir_mtimes =
1199 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1200 }
1201
1202 pub fn has_any_vault_role(&self) -> bool {
1204 for host in &self.hosts_state.list {
1205 if host.vault_ssh.is_some() {
1206 return true;
1207 }
1208 }
1209 for section in &self.providers.config.sections {
1210 if !section.vault_role.is_empty() {
1211 return true;
1212 }
1213 }
1214 false
1215 }
1216
1217 pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
1219 self.tunnels.poll()
1220 }
1221
1222 pub fn refresh_tunnel_bind_ports(&mut self) {
1227 let mut ports: Vec<(String, u16, u32)> = Vec::new();
1228 for (alias, tunnel) in &self.tunnels.active {
1229 let pid = tunnel.child.id();
1230 for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
1231 ports.push((alias.clone(), rule.bind_port, pid));
1232 }
1233 }
1234 self.tunnels.set_lsof_ports(ports);
1235 }
1236}
1237
1238pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
1240 if len == 0 {
1241 return;
1242 }
1243 let i = match state.selected() {
1244 Some(i) => {
1245 if forward {
1246 if i >= len - 1 { 0 } else { i + 1 }
1247 } else if i == 0 {
1248 len - 1
1249 } else {
1250 i - 1
1251 }
1252 }
1253 None => 0,
1254 };
1255 state.select(Some(i));
1256}
1257
1258pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
1260 if len == 0 {
1261 return;
1262 }
1263 let current = state.selected().unwrap_or(0);
1264 let next = (current + page_size).min(len - 1);
1265 state.select(Some(next));
1266}
1267
1268pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
1270 if len == 0 {
1271 return;
1272 }
1273 let current = state.selected().unwrap_or(0);
1274 let prev = current.saturating_sub(page_size);
1275 state.select(Some(prev));
1276}
1277
1278pub use jump::{
1282 ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, JumpMode, JumpState, RecentRef,
1283 RecentsFile, SnippetHit, SourceKind, TunnelHit,
1284};
1285
1286#[cfg(test)]
1290pub type PaletteCommand = JumpAction;
1291
1292static ALL_JUMP_ACTIONS: &[JumpAction] = &[
1299 JumpAction {
1300 key: 'a',
1301 key_str: "a",
1302 label: "Hosts: Add host",
1303 aliases: &["new", "create"],
1304 target: JumpActionTarget::Hosts,
1305 },
1306 JumpAction {
1307 key: 'A',
1308 key_str: "A",
1309 label: "Hosts: Add pattern",
1310 aliases: &["new pattern", "wildcard"],
1311 target: JumpActionTarget::Hosts,
1312 },
1313 JumpAction {
1314 key: 'e',
1315 key_str: "e",
1316 label: "Hosts: Edit host",
1317 aliases: &["modify", "change"],
1318 target: JumpActionTarget::Hosts,
1319 },
1320 JumpAction {
1321 key: 'd',
1322 key_str: "d",
1323 label: "Hosts: Delete host",
1324 aliases: &["remove", "rm"],
1325 target: JumpActionTarget::Hosts,
1326 },
1327 JumpAction {
1328 key: 'c',
1329 key_str: "c",
1330 label: "Hosts: Clone host",
1331 aliases: &["duplicate", "copy"],
1332 target: JumpActionTarget::Hosts,
1333 },
1334 JumpAction {
1335 key: 'u',
1336 key_str: "u",
1337 label: "Hosts: Undo delete",
1338 aliases: &["restore"],
1339 target: JumpActionTarget::Hosts,
1340 },
1341 JumpAction {
1342 key: 't',
1343 key_str: "t",
1344 label: "Hosts: Tag host",
1345 aliases: &["label", "category"],
1346 target: JumpActionTarget::Hosts,
1347 },
1348 JumpAction {
1349 key: 'i',
1350 key_str: "i",
1351 label: "Hosts: Show all directives",
1352 aliases: &["raw", "config", "settings"],
1353 target: JumpActionTarget::Hosts,
1354 },
1355 JumpAction {
1356 key: 'y',
1357 key_str: "y",
1358 label: "Clipboard: Copy SSH command",
1359 aliases: &["yank"],
1360 target: JumpActionTarget::Hosts,
1361 },
1362 JumpAction {
1363 key: 'x',
1364 key_str: "x",
1365 label: "Clipboard: Copy config block",
1366 aliases: &["yank config"],
1367 target: JumpActionTarget::Hosts,
1368 },
1369 JumpAction {
1370 key: 'X',
1371 key_str: "X",
1372 label: "Hosts: Purge stale hosts",
1373 aliases: &["clean", "cleanup"],
1374 target: JumpActionTarget::Hosts,
1375 },
1376 JumpAction {
1377 key: 'F',
1378 key_str: "F",
1379 label: "Files: Browse remote files",
1380 aliases: &[
1381 "browse",
1382 "filesystem",
1383 "scp",
1384 "sftp",
1385 "transfer",
1386 "explorer",
1387 "open",
1388 ],
1389 target: JumpActionTarget::Hosts,
1390 },
1391 JumpAction {
1392 key: 'C',
1393 key_str: "C",
1394 label: "Containers: List containers",
1395 aliases: &["docker", "podman", "ps", "open"],
1396 target: JumpActionTarget::Hosts,
1397 },
1398 JumpAction {
1399 key: 'K',
1400 key_str: "K",
1401 label: "Keys: Manage SSH keys",
1402 aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
1403 target: JumpActionTarget::Hosts,
1404 },
1405 JumpAction {
1406 key: 'S',
1407 key_str: "S",
1408 label: "Providers: Manage cloud sync",
1409 aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
1410 target: JumpActionTarget::Hosts,
1411 },
1412 JumpAction {
1413 key: 'V',
1414 key_str: "V",
1415 label: "Vault: Sign certificate",
1416 aliases: &["hashicorp", "ssh cert", "vault ssh"],
1417 target: JumpActionTarget::Hosts,
1418 },
1419 JumpAction {
1420 key: 'I',
1421 key_str: "I",
1422 label: "Hosts: Import from known_hosts",
1423 aliases: &["known", "import"],
1424 target: JumpActionTarget::Hosts,
1425 },
1426 JumpAction {
1427 key: 'm',
1428 key_str: "m",
1429 label: "Settings: Switch theme",
1430 aliases: &["color", "appearance", "dark", "light"],
1431 target: JumpActionTarget::Hosts,
1432 },
1433 JumpAction {
1434 key: 'n',
1435 key_str: "n",
1436 label: "Help: What's new",
1437 aliases: &["changelog", "news", "release notes"],
1438 target: JumpActionTarget::Hosts,
1439 },
1440 JumpAction {
1441 key: 'r',
1442 key_str: "r",
1443 label: "Snippets: Run snippet",
1444 aliases: &["execute", "command"],
1445 target: JumpActionTarget::Hosts,
1446 },
1447 JumpAction {
1448 key: 'R',
1449 key_str: "R",
1450 label: "Snippets: Run on all visible",
1451 aliases: &["batch", "execute all"],
1452 target: JumpActionTarget::Hosts,
1453 },
1454 JumpAction {
1455 key: 'p',
1456 key_str: "p",
1457 label: "Hosts: Ping host",
1458 aliases: &["health", "check"],
1459 target: JumpActionTarget::Hosts,
1460 },
1461 JumpAction {
1462 key: 'P',
1463 key_str: "P",
1464 label: "Hosts: Ping all hosts",
1465 aliases: &["health all"],
1466 target: JumpActionTarget::Hosts,
1467 },
1468 JumpAction {
1469 key: '!',
1470 key_str: "!",
1471 label: "Hosts: Show down only",
1472 aliases: &["filter offline", "down only"],
1473 target: JumpActionTarget::Hosts,
1474 },
1475 JumpAction {
1479 key: 'T',
1480 key_str: "T",
1481 label: "Tunnels: Manage tunnels",
1482 aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
1483 target: JumpActionTarget::Hosts,
1484 },
1485 JumpAction {
1486 key: 'a',
1487 key_str: "a",
1488 label: "Tunnels: Add tunnel",
1489 aliases: &["new tunnel", "create tunnel", "forward"],
1490 target: JumpActionTarget::Tunnels,
1491 },
1492 JumpAction {
1493 key: 'e',
1494 key_str: "e",
1495 label: "Tunnels: Edit tunnel",
1496 aliases: &["modify tunnel"],
1497 target: JumpActionTarget::Tunnels,
1498 },
1499 JumpAction {
1500 key: 'd',
1501 key_str: "d",
1502 label: "Tunnels: Delete tunnel",
1503 aliases: &["remove tunnel"],
1504 target: JumpActionTarget::Tunnels,
1505 },
1506 JumpAction {
1507 key: 's',
1508 key_str: "s",
1509 label: "Tunnels: Sort",
1510 aliases: &["order tunnels"],
1511 target: JumpActionTarget::Tunnels,
1512 },
1513 JumpAction {
1514 key: 'R',
1515 key_str: "R",
1516 label: "Containers: Refresh all hosts",
1517 aliases: &["reload containers", "fetch", "rescan"],
1518 target: JumpActionTarget::Containers,
1519 },
1520 JumpAction {
1521 key: 's',
1522 key_str: "s",
1523 label: "Containers: Cycle sort",
1524 aliases: &["order containers", "sort by host", "sort by name"],
1525 target: JumpActionTarget::Containers,
1526 },
1527 JumpAction {
1528 key: 'v',
1529 key_str: "v",
1530 label: "Containers: Toggle detail panel",
1531 aliases: &["show details", "hide details", "compact view"],
1532 target: JumpActionTarget::Containers,
1533 },
1534 JumpAction {
1538 key: 'c',
1539 key_str: "c",
1540 label: "Keys: Copy public key",
1541 aliases: &["yank", "clipboard", "pubkey"],
1542 target: JumpActionTarget::Keys,
1543 },
1544 JumpAction {
1545 key: 'p',
1546 key_str: "p",
1547 label: "Keys: Push to host",
1548 aliases: &["install", "ssh-copy-id", "deploy", "upload"],
1549 target: JumpActionTarget::Keys,
1550 },
1551 JumpAction {
1552 key: 'V',
1553 key_str: "V",
1554 label: "Keys: Sign Vault SSH certificate",
1555 aliases: &["vault", "renew cert", "sign"],
1556 target: JumpActionTarget::Keys,
1557 },
1558];
1559
1560pub const PALETTE_PER_SECTION_CAP: usize = 32;
1565
1566pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
1569 if let Some((prefix, rest)) = query.split_once(':') {
1570 let scope = match prefix.trim() {
1571 "user" => Some(QueryScope::User),
1572 "host" => Some(QueryScope::Hostname),
1573 "proxy" => Some(QueryScope::ProxyJump),
1574 "vault" => Some(QueryScope::VaultSsh),
1575 "tag" => Some(QueryScope::Tag),
1576 _ => None,
1577 };
1578 if scope.is_some() {
1579 return (scope, rest.trim_start());
1580 }
1581 }
1582 (None, query)
1583}
1584
1585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1586pub enum QueryScope {
1587 User,
1588 Hostname,
1589 ProxyJump,
1590 VaultSsh,
1591 Tag,
1592}
1593
1594fn preview(s: &str, max: usize) -> String {
1596 let s = s.replace('\n', " ");
1597 let chars: Vec<char> = s.chars().collect();
1598 if chars.len() <= max {
1599 s
1600 } else {
1601 let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
1602 out.push_str("...");
1603 out
1604 }
1605}
1606
1607fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
1612 let scope = scope?;
1613 match (hit, scope) {
1614 (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
1615 (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
1616 Some(vec![&h.hostname])
1617 }
1618 (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
1619 Some(vec![&h.proxy_jump])
1620 }
1621 (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
1622 (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
1623 _ => None,
1625 }
1626}
1627
1628pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
1633 if query.is_empty() {
1634 return None;
1635 }
1636 let q = query.to_lowercase();
1637 let alias_hit = host.alias.to_lowercase().contains(&q);
1638 let hostname_hit = host.hostname.to_lowercase().contains(&q);
1639 if alias_hit || hostname_hit {
1640 return None;
1641 }
1642 if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
1643 return Some(MatchSource::User);
1644 }
1645 if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
1646 return Some(MatchSource::ProxyJump);
1647 }
1648 if let Some(role) = &host.vault_ssh {
1649 if role.to_lowercase().contains(&q) {
1650 return Some(MatchSource::VaultSsh);
1651 }
1652 }
1653 if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
1654 return Some(MatchSource::IdentityFile);
1655 }
1656 None
1657}
1658
1659#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1660pub enum MatchSource {
1661 User,
1662 ProxyJump,
1663 VaultSsh,
1664 IdentityFile,
1665}
1666
1667fn kind_rank(k: SourceKind) -> u8 {
1668 match k {
1669 SourceKind::Host => 0,
1670 SourceKind::Tunnel => 1,
1671 SourceKind::Container => 2,
1672 SourceKind::Snippet => 3,
1673 SourceKind::Action => 4,
1674 }
1675}
1676
1677fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
1682 if let Some(target) = prior {
1683 if let Some(idx) = hits.iter().position(|h| &h.identity() == target) {
1684 return idx;
1685 }
1686 }
1687 fallback.min(hits.len().saturating_sub(1))
1688}
1689
1690impl JumpAction {
1691 #[cfg(test)]
1692 pub fn all() -> &'static [JumpAction] {
1693 ALL_JUMP_ACTIONS
1694 }
1695
1696 pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
1700 ALL_JUMP_ACTIONS
1701 }
1702}
1703
1704#[cfg(test)]
1705mod tests;