1use ratatui::widgets::ListState;
2
3use crate::history::ConnectionHistory;
4use crate::ssh_config::model::SshConfigFile;
5
6pub(super) fn contains_ci(haystack: &str, needle: &str) -> bool {
12 if needle.is_empty() {
13 return true;
14 }
15 if haystack.is_ascii() && needle.is_ascii() {
16 return haystack
17 .as_bytes()
18 .windows(needle.len())
19 .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()));
20 }
21 let needle_lower: Vec<char> = needle.chars().map(|c| c.to_ascii_lowercase()).collect();
23 let haystack_chars: Vec<char> = haystack.chars().collect();
24 haystack_chars.windows(needle_lower.len()).any(|window| {
25 window
26 .iter()
27 .zip(needle_lower.iter())
28 .all(|(h, n)| h.to_ascii_lowercase() == *n)
29 })
30}
31
32pub(super) fn eq_ci(a: &str, b: &str) -> bool {
34 a.eq_ignore_ascii_case(b)
35}
36
37mod baselines;
38mod container_state;
39mod containers_overview;
40mod display_list;
41mod file_browser_state;
42mod form_state;
43mod forms;
44mod groups;
45mod host_state;
46mod hosts;
47pub(crate) use hosts::migrate_renames_persistent_state;
48pub(crate) mod jump;
49mod key_push_state;
50mod keys_state;
51mod pickers;
52pub(crate) mod ping;
53mod provider_state;
54mod reload_state;
55mod screen;
56mod search;
57mod selection;
58mod snippet_state;
59mod status_state;
60mod tag_state;
61mod tunnel_state;
62mod ui_state;
63mod update;
64mod vault;
65
66pub use baselines::{FormBaseline, ProviderFormBaseline, SnippetFormBaseline, TunnelFormBaseline};
67pub use container_state::{ContainerSession, ContainerState};
68pub use containers_overview::{
69 ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest, ContainersOverviewState,
70 ContainersSortMode, InspectCacheEntry, LIST_CACHE_TTL_SECS, LOGS_TAIL, LogsCacheEntry,
71 REFRESH_MAX_PARALLEL, RefreshBatch, RefreshQueueItem,
72};
73pub use file_browser_state::FileBrowserState;
74pub use form_state::FormState;
75pub(crate) use forms::char_to_byte_pos;
76pub use forms::{
77 FormField, HostForm, ProviderFormField, ProviderFormFields, SnippetForm, SnippetFormField,
78 SnippetHostOutput, SnippetOutputState, SnippetParamFormState, TunnelForm, TunnelFormField,
79};
80pub use host_state::{
81 DeletedHost, GroupBy, HostListItem, HostState, ProxyJumpCandidate, SortMode, ViewMode,
82 health_summary_spans, health_summary_spans_for,
83};
84pub use key_push_state::KeyPushState;
85pub use keys_state::KeysState;
86pub use ping::{
87 PingState, PingStatus, classify_ping, ping_sort_key, propagate_ping_to_dependents, status_glyph,
88};
89pub use provider_state::{
90 LabelMigrationField, PendingLabelMigration, ProviderRow, ProviderState, SyncRecord,
91};
92pub use reload_state::{ConflictState, ReloadState};
93pub use screen::{ContainerLogsSearch, Screen, StackMember, TopPage, WhatsNewState};
94pub use search::SearchState;
95pub use snippet_state::SnippetState;
96pub use status_state::{MessageClass, StatusCenter, StatusMessage};
97pub use tag_state::{
98 BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, TagState,
99 select_display_tags,
100};
101pub use tunnel_state::{TunnelSortMode, TunnelState};
102pub use ui_state::UiSelection;
103pub use update::UpdateState;
104pub use vault::VaultState;
105
106impl Drop for App {
108 fn drop(&mut self) {
109 for (alias, mut tunnel) in self.tunnels.active.drain() {
110 if let Err(e) = tunnel.child.kill() {
111 log::debug!("[external] Failed to kill tunnel for {alias} on shutdown: {e}");
112 }
113 let _ = tunnel.child.wait();
114 }
115 if let Some(handle) = self.vault.cancel_signing_run() {
119 let _ = handle.join();
120 }
121 self.keys.push.shutdown();
125 }
126}
127
128pub struct App {
130 pub screen: Screen,
133 pub top_page: TopPage,
136 pub running: bool,
138 pub(crate) hosts_state: HostState,
140
141 pub(crate) status_center: StatusCenter,
144 pub(crate) ui: UiSelection,
146 pub(crate) search: SearchState,
148 pub(crate) reload: ReloadState,
150 pub(crate) conflict: ConflictState,
152
153 pub(crate) keys: KeysState,
155
156 pub(crate) tags: TagState,
158
159 pub(crate) forms: FormState,
161
162 pub(crate) history: ConnectionHistory,
164
165 pub(crate) providers: ProviderState,
167
168 pub(crate) ping: PingState,
170
171 pub(crate) vault: VaultState,
173
174 pub(crate) tunnels: TunnelState,
176
177 pub(crate) snippets: SnippetState,
179
180 pub(crate) update: UpdateState,
182
183 pub bw_session: Option<String>,
185
186 pub(crate) file_browser_state: FileBrowserState,
189 pub(crate) file_browser_session: Option<crate::file_browser::FileBrowserSession>,
191
192 pub(crate) container_state: ContainerState,
195 pub(crate) container_session: Option<ContainerSession>,
197 pub(crate) containers_overview: ContainersOverviewState,
199
200 pub demo_mode: bool,
202
203 pub(crate) env: std::sync::Arc<crate::runtime::env::Env>,
207
208 pub(crate) jump: Option<JumpState>,
210}
211
212impl App {
213 pub fn new(config: SshConfigFile) -> Self {
217 #[cfg(test)]
218 let env = std::sync::Arc::new(crate::runtime::env::Env::sandboxed());
219 #[cfg(not(test))]
220 let env = std::sync::Arc::new(crate::runtime::env::Env::from_process());
221 Self::with_env(config, env)
222 }
223
224 pub fn with_env(config: SshConfigFile, env: std::sync::Arc<crate::runtime::env::Env>) -> Self {
228 let hosts = config.host_entries();
229 let patterns = config.pattern_entries();
230 let display_list = Self::build_display_list_from(&config, &hosts, &patterns);
231
232 let initial_selection = display_list.iter().position(|item| {
233 matches!(
234 item,
235 HostListItem::Host { .. } | HostListItem::Pattern { .. }
236 )
237 });
238
239 let reload = ReloadState::from_config(&config);
240 let hosts_state = HostState::from_config(config, hosts, patterns, display_list);
241
242 Self {
243 screen: Screen::HostList,
244 top_page: TopPage::default(),
245 running: true,
246 hosts_state,
247 status_center: StatusCenter::default(),
248 ui: UiSelection::new_with_initial_selection(initial_selection),
249 search: SearchState::default(),
250 reload,
251 conflict: ConflictState::default(),
252 keys: KeysState {
253 list: Vec::new(),
254 list_state: ratatui::widgets::ListState::default(),
255 activity: crate::key_activity::KeyActivityLog::load(),
256 push: KeyPushState::default(),
257 },
258 tags: TagState::default(),
259 forms: FormState::default(),
260 history: ConnectionHistory::load(),
261 providers: ProviderState::load(),
262 ping: PingState::from_preferences(env.paths()),
263 vault: VaultState::default(),
264 tunnels: TunnelState::default(),
265 snippets: SnippetState::with_store_loaded(),
266 update: UpdateState::with_current_hint(),
267 bw_session: None,
268 file_browser_state: FileBrowserState::default(),
269 file_browser_session: None,
270 container_state: ContainerState {
271 cache: crate::containers::load_container_cache(env.paths()),
272 ..ContainerState::default()
273 },
274 container_session: None,
275 containers_overview: ContainersOverviewState::default(),
276 demo_mode: false,
277 env,
278 jump: None,
279 }
280 }
281
282 pub(crate) fn env(&self) -> &crate::runtime::env::Env {
284 &self.env
285 }
286
287 pub fn record_key_use(&mut self, alias: &str, now: u64) {
293 crate::key_activity::record_and_flush(&mut self.keys.activity, alias, now);
294 }
295
296 pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
300 self.hosts_state
301 .list
302 .iter()
303 .map(|h| h.alias.clone())
304 .collect()
305 }
306
307 pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
313 let new_aliases: Vec<String> = self
314 .hosts_state
315 .list
316 .iter()
317 .filter(|h| !before_aliases.contains(&h.alias))
318 .map(|h| h.alias.clone())
319 .collect();
320 for alias in new_aliases {
321 self.container_state.queue_fetch(alias);
322 }
323 }
324
325 pub fn reload_hosts(&mut self) {
337 let had_pending_vault_write = self.vault.pending_config_write;
338 let mut flushed_vault_write = false;
351 if self.vault.pending_config_write && !self.is_form_open() {
352 if self.external_config_changed() {
353 self.notify_error(
354 crate::messages::vault_config_skipped_external_change().to_string(),
355 );
356 log::warn!(
357 "[config] reload_hosts: skipping deferred vault write. external config changed"
358 );
359 } else {
360 match self.hosts_state.ssh_config.write() {
361 Ok(()) => flushed_vault_write = true,
362 Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
363 }
364 }
365 }
366 self.vault.pending_config_write = false;
369 log::debug!(
370 "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
371 );
372 let had_search = self.search.query.take();
373 let selected_alias = self
374 .selected_host()
375 .map(|h| h.alias.clone())
376 .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
377
378 self.tunnels.summaries_cache.clear();
379 self.hosts_state.render_cache.invalidate();
380 self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
381 self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
382
383 {
388 let valid_aliases: std::collections::HashSet<&str> = self
389 .hosts_state
390 .list
391 .iter()
392 .map(|h| h.alias.as_str())
393 .collect();
394
395 self.vault.prune_orphans(&valid_aliases);
396
397 if self.container_state.prune_orphans(&valid_aliases) {
402 crate::containers::save_container_cache(
403 self.env().paths(),
404 self.container_state.cache(),
405 );
406 }
407
408 let valid_container_ids: std::collections::HashSet<String> = self
412 .container_state
413 .cache()
414 .values()
415 .flat_map(|e| e.containers.iter().map(|c| c.id.clone()))
416 .collect();
417 self.containers_overview
418 .prune_by_container_ids(&valid_container_ids);
419
420 if self.containers_overview.prune_orphans(&valid_aliases) {
424 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
425 self.env().paths(),
426 self.containers_overview.collapsed_hosts(),
427 ) {
428 log::warn!("[config] failed to save collapsed_hosts after prune: {e}");
429 }
430 }
431
432 self.file_browser_state.prune_orphans(&valid_aliases);
433 self.tunnels.prune_orphans(&valid_aliases);
434 self.ping.prune_orphans(&valid_aliases);
435 }
436
437 if self.hosts_state.sort_mode == SortMode::Original
438 && matches!(self.hosts_state.group_by, GroupBy::None)
439 {
440 self.hosts_state.display_list = Self::build_display_list_from(
441 &self.hosts_state.ssh_config,
442 &self.hosts_state.list,
443 &self.hosts_state.patterns,
444 );
445 } else {
446 self.apply_sort();
447 }
448
449 if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
451 self.set_screen(Screen::HostList);
452 self.forms.bulk_tag_editor = BulkTagEditorState::default();
453 }
454
455 self.hosts_state.multi_select.clear();
457
458 if let Some(query) = had_search {
460 self.search.query = Some(query);
461 self.apply_filter();
462 } else {
463 self.search.query = None;
464 self.search.filtered_indices.clear();
465 self.search.filtered_pattern_indices.clear();
466 if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
468 self.ui.list_state.select(None);
469 } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
470 matches!(
471 item,
472 HostListItem::Host { .. } | HostListItem::Pattern { .. }
473 )
474 }) {
475 let current = self.ui.list_state.selected().unwrap_or(0);
476 if current >= self.hosts_state.display_list.len()
477 || !matches!(
478 self.hosts_state.display_list.get(current),
479 Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
480 )
481 {
482 self.ui.list_state.select(Some(pos));
483 }
484 } else {
485 self.ui.list_state.select(None);
486 }
487 }
488
489 if let Some(alias) = selected_alias {
491 self.select_host_by_alias(&alias);
492 }
493
494 log::debug!(
495 "[config] reload_hosts: hosts={} patterns={} display_items={}",
496 self.hosts_state.list.len(),
497 self.hosts_state.patterns.len(),
498 self.hosts_state.display_list.len(),
499 );
500 }
501
502 pub fn refresh_cert_cache(&mut self, alias: &str) {
513 if crate::demo_flag::is_demo() {
514 return;
515 }
516 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
517 self.vault.cert_cache.remove(alias);
518 return;
519 };
520 let role_some = crate::vault_ssh::resolve_vault_role(
521 host.vault_ssh.as_deref(),
522 host.provider.as_deref(),
523 host.provider_label.as_deref(),
524 &self.providers.config,
525 )
526 .is_some();
527 if !role_some {
528 self.vault.cert_cache.remove(alias);
529 return;
530 }
531 let cert_path = match crate::vault_ssh::resolve_cert_path(
532 self.env().paths(),
533 alias,
534 &host.certificate_file,
535 ) {
536 Ok(p) => p,
537 Err(_) => {
538 self.vault.cert_cache.remove(alias);
539 return;
540 }
541 };
542 let status = crate::vault_ssh::check_cert_validity(self.env(), &cert_path);
543 let mtime = std::fs::metadata(&cert_path)
544 .ok()
545 .and_then(|m| m.modified().ok());
546 self.vault.cert_cache.insert(
547 alias.to_string(),
548 (std::time::Instant::now(), status, mtime),
549 );
550 }
551
552 #[cfg(test)]
559 pub fn sorted_provider_names(&self) -> Vec<String> {
560 self.providers.sorted_names()
561 }
562
563 pub fn is_form_open(&self) -> bool {
565 matches!(
566 self.screen,
567 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
568 )
569 }
570
571 pub fn open_jump(&mut self, mode: JumpMode) {
574 log::debug!("jump: open mode={:?}", mode);
575 let mut state = JumpState::for_mode(mode);
576 let recents_file = jump::load_recents();
577 state.recents = self.resolve_recents(&recents_file);
578 self.jump = Some(state);
579 self.recompute_jump_hits();
580 }
581
582 pub(crate) fn close_jump(&mut self) {
586 self.jump = None;
587 }
588
589 fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
592 let mode = self
593 .jump
594 .as_ref()
595 .map(|p| p.mode)
596 .unwrap_or(JumpMode::Hosts);
597 let mut out = Vec::with_capacity(file.entries.len());
598 for entry in &file.entries {
599 if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
600 out.push(hit);
601 }
602 }
603 out
604 }
605
606 #[cfg(test)]
610 pub(crate) fn resolve_recent_ref_for_test(
611 &self,
612 r: &RecentRef,
613 mode: JumpMode,
614 ) -> Option<JumpHit> {
615 self.resolve_recent_ref(r, mode)
616 }
617
618 fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
619 match r.kind {
620 SourceKind::Action => {
621 let key_char = r.key.chars().next()?;
622 let actions = JumpAction::for_mode(mode);
623 actions
624 .iter()
625 .find(|a| a.key == key_char)
626 .copied()
627 .map(JumpHit::Action)
628 }
629 SourceKind::Host => {
630 let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
631 Some(JumpHit::Host(HostHit {
632 alias: host.alias.clone(),
633 hostname: host.hostname.clone(),
634 tags: host.tags.clone(),
635 provider: host.provider.clone(),
636 user: host.user.clone(),
637 identity_file: host.identity_file.clone(),
638 proxy_jump: host.proxy_jump.clone(),
639 vault_ssh: host.vault_ssh.clone(),
640 }))
641 }
642 SourceKind::Tunnel => {
643 let (alias, port_str) = r.key.split_once(':')?;
644 let port: u16 = port_str.parse().ok()?;
645 let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
646 let rule = rules.iter().find(|r| r.bind_port == port)?;
647 Some(JumpHit::Tunnel(TunnelHit {
648 alias: alias.to_string(),
649 bind_port: rule.bind_port,
650 bind_port_str: rule.bind_port.to_string(),
651 destination: rule.display(),
652 active: self.tunnels.active.contains_key(alias),
653 }))
654 }
655 SourceKind::Container => {
656 let (alias, name) = r.key.split_once('/')?;
657 let entry = self.container_state.cache.get(alias)?;
658 let info = entry.containers.iter().find(|c| c.names == name)?;
659 Some(JumpHit::Container(ContainerHit {
660 alias: alias.to_string(),
661 container_name: info.names.clone(),
662 container_id: info.id.clone(),
663 state: info.state.clone(),
664 }))
665 }
666 SourceKind::Snippet => {
667 let snippet = self.snippets.store.get(&r.key)?;
668 Some(JumpHit::Snippet(SnippetHit {
669 name: snippet.name.clone(),
670 command_preview: preview(&snippet.command, 40),
671 }))
672 }
673 }
674 }
675
676 pub fn recompute_jump_hits(&mut self) {
682 let Some(mut state) = self.jump.take() else {
683 return;
684 };
685 let prior_identity = state
689 .visible_hits()
690 .get(state.selected)
691 .map(|h| h.identity());
692
693 let candidates = self.collect_jump_candidates(state.mode);
694 if state.query.is_empty() {
695 state.hits = candidates;
696 state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
697 self.jump = Some(state);
698 return;
699 }
700
701 let (scope, effective_query) = parse_query_scope(&state.query);
706
707 use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
708 use nucleo_matcher::{Config, Matcher, Utf32Str};
709 let matcher_state = state
710 .matcher
711 .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
712 let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
713 let mut buf: Vec<char> = Vec::new();
714 let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
715 for hit in candidates {
716 let mut best: u32 = 0;
717 let scoped_haystacks = scoped_haystacks_for(&hit, scope);
721 let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
722 hs
723 } else {
724 hit.haystacks()
725 };
726 for haystack in haystacks {
727 buf.clear();
728 let chars = Utf32Str::new(haystack, &mut buf);
729 if let Some(score) = pattern.score(chars, matcher_state) {
730 best = best.max(score);
731 }
732 }
733 if let JumpHit::Action(a) = &hit {
739 let single = effective_query.chars().next();
740 if effective_query.chars().count() == 1
741 && single
742 .map(|c| c.eq_ignore_ascii_case(&a.key))
743 .unwrap_or(false)
744 {
745 let mode_match = matches!(
746 (state.mode, a.target),
747 (JumpMode::Hosts, JumpActionTarget::Hosts)
748 | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
749 | (JumpMode::Containers, JumpActionTarget::Containers)
750 | (JumpMode::Keys, JumpActionTarget::Keys)
751 );
752 let bump = if mode_match { 20_000 } else { 10_000 };
753 best = best.saturating_add(bump);
754 }
755 }
756 let floor = match &hit {
760 JumpHit::Action(_) => jump::PALETTE_ACTION_FLOOR,
761 _ => 1,
762 };
763 if best >= floor {
764 scored.push((hit, best));
765 }
766 }
767 scored.sort_by(|a, b| {
770 b.1.cmp(&a.1)
771 .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
772 });
773 let mut per_kind: [usize; 5] = [0; 5];
776 let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
777 for (hit, _) in scored {
778 let slot = kind_rank(hit.kind()) as usize;
779 if per_kind[slot] < PALETTE_PER_SECTION_CAP {
780 per_kind[slot] += 1;
781 filtered.push(hit);
782 }
783 }
784 state.hits = filtered;
785 let display = state.visible_hits();
794 let top_display = state
795 .hits
796 .first()
797 .map(|h| h.kind())
798 .and_then(|k| display.iter().position(|h| h.kind() == k))
799 .unwrap_or(0);
800 state.selected = restore_selection(&display, prior_identity.as_ref(), top_display);
801 log::debug!(
802 "jump: recompute selected={} of {} hits (top_display={})",
803 state.selected,
804 state.hits.len(),
805 top_display
806 );
807 self.jump = Some(state);
808 }
809
810 fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
811 let mut out: Vec<JumpHit> = Vec::new();
812 for h in &self.hosts_state.list {
814 out.push(JumpHit::Host(HostHit {
815 alias: h.alias.clone(),
816 hostname: h.hostname.clone(),
817 tags: h.tags.clone(),
818 provider: h.provider.clone(),
819 user: h.user.clone(),
820 identity_file: h.identity_file.clone(),
821 proxy_jump: h.proxy_jump.clone(),
822 vault_ssh: h.vault_ssh.clone(),
823 }));
824 }
825 for h in &self.hosts_state.list {
827 let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
828 for rule in rules {
829 out.push(JumpHit::Tunnel(TunnelHit {
830 alias: h.alias.clone(),
831 bind_port: rule.bind_port,
832 bind_port_str: rule.bind_port.to_string(),
833 destination: rule.display(),
834 active: self.tunnels.active.contains_key(&h.alias),
835 }));
836 }
837 }
838 for (alias, entry) in &self.container_state.cache {
841 for info in &entry.containers {
842 out.push(JumpHit::Container(ContainerHit {
843 alias: alias.clone(),
844 container_name: info.names.clone(),
845 container_id: info.id.clone(),
846 state: info.state.clone(),
847 }));
848 }
849 }
850 for snippet in &self.snippets.store.snippets {
852 out.push(JumpHit::Snippet(SnippetHit {
853 name: snippet.name.clone(),
854 command_preview: preview(&snippet.command, 40),
855 }));
856 }
857 for a in JumpAction::for_mode(mode) {
859 out.push(JumpHit::Action(*a));
860 }
861 out
862 }
863
864 pub fn record_jump_hit(&mut self, hit: &JumpHit) {
870 if self.demo_mode {
871 log::debug!("jump: record skipped (demo mode)");
872 return;
873 }
874 let mut file = jump::load_recents();
875 jump::touch_recent(&mut file, hit.identity());
876 if let Err(e) = jump::save_recents(&file) {
877 log::warn!("[purple] failed to save recents: {e}");
878 }
879 }
880
881 pub(crate) fn open_file_browser(&mut self, session: crate::file_browser::FileBrowserSession) {
885 let alias = session.alias.clone();
886 self.file_browser_session = Some(session);
887 self.set_screen(Screen::FileBrowser { alias });
888 }
889
890 pub(crate) fn close_file_browser(&mut self) {
894 if let Some(fb) = self.file_browser_session.take() {
895 self.file_browser_state
896 .host_paths
897 .insert(fb.alias, (fb.local_path, fb.remote_path));
898 }
899 self.set_screen(Screen::HostList);
900 }
901
902 pub fn flush_pending_vault_write(&mut self) -> bool {
905 if !self.vault.pending_config_write || self.is_form_open() {
906 return false;
907 }
908 self.reload_hosts();
910 true
911 }
912
913 pub fn post_init(&mut self) {
917 let outcome = crate::onboarding::evaluate(self.env().paths());
918 if let Some(text) = outcome.upgrade_toast {
919 self.enqueue_sticky_toast(text);
920 }
921 self.scan_keys();
925 }
926
927 fn enqueue_sticky_toast(&mut self, text: String) {
928 log::debug!("[purple] enqueue sticky toast: {}", text);
929 let msg = StatusMessage {
930 text,
931 class: MessageClass::Success,
932 tick_count: 0,
933 sticky: true,
934 created_at: std::time::Instant::now(),
935 };
936 self.status_center.toast = Some(msg);
937 }
938
939 pub fn notify(&mut self, text: impl Into<String>) {
941 self.status_center.set_status(text, false);
942 }
943
944 pub fn notify_error(&mut self, text: impl Into<String>) {
946 self.status_center.set_status(text, true);
947 }
948
949 pub fn notify_background(&mut self, text: impl Into<String>) {
951 self.status_center.set_background_status(text, false);
952 }
953
954 pub fn notify_background_error(&mut self, text: impl Into<String>) {
956 self.status_center.set_background_status(text, true);
957 }
958
959 pub fn notify_warning(&mut self, text: impl Into<String>) {
971 let msg = StatusMessage {
972 text: text.into(),
973 class: MessageClass::Warning,
974 tick_count: 0,
975 sticky: false,
976 created_at: std::time::Instant::now(),
977 };
978 log::debug!("toast <- Warning: {}", msg.text);
979 self.status_center.push_toast(msg);
980 }
981
982 pub fn notify_progress(&mut self, text: impl Into<String>) {
984 self.status_center.set_sticky_status(text, false);
985 }
986
987 pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
989 self.status_center.set_sticky_status(text, true);
990 }
991
992 pub fn notify_info(&mut self, text: impl Into<String>) {
994 self.status_center.set_info_status(text);
995 }
996
997 pub(crate) fn clear_status(&mut self) {
1002 self.status_center.clear_status();
1003 }
1004
1005 pub fn tick_status(&mut self) {
1012 if !self.providers.syncing.is_empty() {
1014 return;
1015 }
1016 if let Some(ref status) = self.status_center.status {
1017 if status.sticky {
1018 return;
1019 }
1020 let timeout_ms = status.timeout_ms();
1021 if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
1022 {
1023 log::debug!("footer status expired: {}", status.text);
1024 self.status_center.status = None;
1025 }
1026 }
1027 }
1028
1029 pub fn tick_toast(&mut self) {
1031 self.status_center.tick_toast();
1032 }
1033
1034 pub fn check_config_changed(&mut self) {
1038 if matches!(
1039 self.screen,
1040 Screen::AddHost
1041 | Screen::EditHost { .. }
1042 | Screen::ProviderForm { .. }
1043 | Screen::TunnelList { .. }
1044 | Screen::TunnelForm { .. }
1045 | Screen::HostDetail { .. }
1046 | Screen::SnippetPicker { .. }
1047 | Screen::SnippetForm { .. }
1048 | Screen::SnippetOutput { .. }
1049 | Screen::SnippetParamForm { .. }
1050 | Screen::FileBrowser { .. }
1051 | Screen::Containers { .. }
1052 | Screen::ConfirmDelete { .. }
1053 | Screen::ConfirmHostKeyReset { .. }
1054 | Screen::ConfirmPurgeStale { .. }
1055 | Screen::ConfirmImport { .. }
1056 | Screen::ConfirmVaultSign { .. }
1057 | Screen::TagPicker
1058 | Screen::BulkTagEditor
1059 | Screen::ThemePicker
1060 | Screen::WhatsNew(_)
1061 ) || self.tags.input.is_some()
1062 {
1063 return;
1064 }
1065 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1066 let changed = current_mtime != self.reload.last_modified
1067 || self
1068 .reload
1069 .include_mtimes
1070 .iter()
1071 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1072 || self
1073 .reload
1074 .include_dir_mtimes
1075 .iter()
1076 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
1077 if changed {
1078 log::debug!(
1079 "[config] check_config_changed: mtime drift detected on {} -> reloading",
1080 self.reload.config_path.display()
1081 );
1082 if let Ok(new_config) = SshConfigFile::parse(&self.reload.config_path) {
1083 let before_aliases = self.snapshot_alias_set();
1084 self.hosts_state.ssh_config = new_config;
1085 self.hosts_state.undo_stack.clear();
1087 log::debug!(
1089 "[config] external config change: clearing {} ping result(s) + timestamps",
1090 self.ping.status.len()
1091 );
1092 self.ping.status.clear();
1093 self.ping.last_checked.clear();
1094 self.ping.filter_down_only = false;
1095 self.ping.checked_at = None;
1096 self.reload_hosts();
1097 self.reload.last_modified = current_mtime;
1098 self.reload.include_mtimes =
1099 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1100 self.reload.include_dir_mtimes =
1101 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1102 let count = self.hosts_state.list.len();
1103 self.notify_background(crate::messages::config_reloaded(count));
1104 self.queue_new_aliases_since(&before_aliases);
1105 }
1106 }
1107 }
1108
1109 pub fn check_keys_changed(&mut self) {
1119 if self.demo_mode {
1120 return;
1121 }
1122 if matches!(
1123 self.screen,
1124 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
1125 ) {
1126 return;
1127 }
1128 let Some(ssh_dir) = self.env().paths().map(crate::runtime::env::Paths::ssh_dir) else {
1129 return;
1130 };
1131 let current_dir_mtime = reload_state::get_mtime(&ssh_dir);
1132 let dir_changed = current_dir_mtime != self.reload.keys_dir_mtime;
1133 let files_changed = self
1134 .reload
1135 .key_file_mtimes
1136 .iter()
1137 .any(|(path, old)| reload_state::get_mtime(path) != *old);
1138 if !dir_changed && !files_changed {
1139 return;
1140 }
1141 log::debug!(
1142 "[purple] check_keys_changed: drift detected on {} (dir={} files={}) -> rescan",
1143 ssh_dir.display(),
1144 dir_changed,
1145 files_changed,
1146 );
1147 let previous = self.keys.list.len();
1148 self.scan_keys();
1149 let after = self.keys.list.len();
1150 if let Some(sel) = self.keys.list_state.selected() {
1153 if sel >= after {
1154 let next = after.checked_sub(1);
1155 self.keys.list_state.select(next);
1156 }
1157 } else if after > 0 {
1158 self.keys.list_state.select(Some(0));
1159 }
1160 if previous != after {
1161 log::debug!(
1162 "[purple] check_keys_changed: rescan {} -> {} keys",
1163 previous,
1164 after
1165 );
1166 }
1167 }
1168
1169 pub fn external_config_changed(&self) -> bool {
1178 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1179 current_mtime != self.reload.last_modified
1180 || self
1181 .reload
1182 .include_mtimes
1183 .iter()
1184 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1185 || self
1186 .reload
1187 .include_dir_mtimes
1188 .iter()
1189 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1190 }
1191
1192 pub fn update_last_modified(&mut self) {
1194 self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
1195 self.reload.include_mtimes =
1196 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1197 self.reload.include_dir_mtimes =
1198 reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1199 }
1200
1201 pub fn has_any_vault_role(&self) -> bool {
1203 for host in &self.hosts_state.list {
1204 if host.vault_ssh.is_some() {
1205 return true;
1206 }
1207 }
1208 for section in &self.providers.config.sections {
1209 if !section.vault_role.is_empty() {
1210 return true;
1211 }
1212 }
1213 false
1214 }
1215
1216 pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
1218 self.tunnels.poll()
1219 }
1220
1221 pub fn refresh_tunnel_bind_ports(&mut self) {
1226 let mut ports: Vec<(String, u16, u32)> = Vec::new();
1227 for (alias, tunnel) in &self.tunnels.active {
1228 let pid = tunnel.child.id();
1229 for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
1230 ports.push((alias.clone(), rule.bind_port, pid));
1231 }
1232 }
1233 self.tunnels.set_lsof_ports(ports);
1234 }
1235}
1236
1237pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
1239 if len == 0 {
1240 return;
1241 }
1242 let i = match state.selected() {
1243 Some(i) => {
1244 if forward {
1245 if i >= len - 1 { 0 } else { i + 1 }
1246 } else if i == 0 {
1247 len - 1
1248 } else {
1249 i - 1
1250 }
1251 }
1252 None => 0,
1253 };
1254 state.select(Some(i));
1255}
1256
1257pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
1259 if len == 0 {
1260 return;
1261 }
1262 let current = state.selected().unwrap_or(0);
1263 let next = (current + page_size).min(len - 1);
1264 state.select(Some(next));
1265}
1266
1267pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
1269 if len == 0 {
1270 return;
1271 }
1272 let current = state.selected().unwrap_or(0);
1273 let prev = current.saturating_sub(page_size);
1274 state.select(Some(prev));
1275}
1276
1277pub use jump::{
1281 ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, JumpMode, JumpState, RecentRef,
1282 RecentsFile, SnippetHit, SourceKind, TunnelHit,
1283};
1284
1285#[cfg(test)]
1289pub type PaletteCommand = JumpAction;
1290
1291static ALL_JUMP_ACTIONS: &[JumpAction] = &[
1298 JumpAction {
1299 key: 'a',
1300 key_str: "a",
1301 label: "Hosts: Add host",
1302 aliases: &["new", "create"],
1303 target: JumpActionTarget::Hosts,
1304 },
1305 JumpAction {
1306 key: 'A',
1307 key_str: "A",
1308 label: "Hosts: Add pattern",
1309 aliases: &["new pattern", "wildcard"],
1310 target: JumpActionTarget::Hosts,
1311 },
1312 JumpAction {
1313 key: 'e',
1314 key_str: "e",
1315 label: "Hosts: Edit host",
1316 aliases: &["modify", "change"],
1317 target: JumpActionTarget::Hosts,
1318 },
1319 JumpAction {
1320 key: 'd',
1321 key_str: "d",
1322 label: "Hosts: Delete host",
1323 aliases: &["remove", "rm"],
1324 target: JumpActionTarget::Hosts,
1325 },
1326 JumpAction {
1327 key: 'c',
1328 key_str: "c",
1329 label: "Hosts: Clone host",
1330 aliases: &["duplicate", "copy"],
1331 target: JumpActionTarget::Hosts,
1332 },
1333 JumpAction {
1334 key: 'u',
1335 key_str: "u",
1336 label: "Hosts: Undo delete",
1337 aliases: &["restore"],
1338 target: JumpActionTarget::Hosts,
1339 },
1340 JumpAction {
1341 key: 't',
1342 key_str: "t",
1343 label: "Hosts: Tag host",
1344 aliases: &["label", "category"],
1345 target: JumpActionTarget::Hosts,
1346 },
1347 JumpAction {
1348 key: 'i',
1349 key_str: "i",
1350 label: "Hosts: Show all directives",
1351 aliases: &["raw", "config", "settings"],
1352 target: JumpActionTarget::Hosts,
1353 },
1354 JumpAction {
1355 key: 'y',
1356 key_str: "y",
1357 label: "Clipboard: Copy SSH command",
1358 aliases: &["yank"],
1359 target: JumpActionTarget::Hosts,
1360 },
1361 JumpAction {
1362 key: 'x',
1363 key_str: "x",
1364 label: "Clipboard: Copy config block",
1365 aliases: &["yank config"],
1366 target: JumpActionTarget::Hosts,
1367 },
1368 JumpAction {
1369 key: 'X',
1370 key_str: "X",
1371 label: "Hosts: Purge stale hosts",
1372 aliases: &["clean", "cleanup"],
1373 target: JumpActionTarget::Hosts,
1374 },
1375 JumpAction {
1376 key: 'F',
1377 key_str: "F",
1378 label: "Files: Browse remote files",
1379 aliases: &[
1380 "browse",
1381 "filesystem",
1382 "scp",
1383 "sftp",
1384 "transfer",
1385 "explorer",
1386 "open",
1387 ],
1388 target: JumpActionTarget::Hosts,
1389 },
1390 JumpAction {
1391 key: 'C',
1392 key_str: "C",
1393 label: "Containers: List containers",
1394 aliases: &["docker", "podman", "ps", "open"],
1395 target: JumpActionTarget::Hosts,
1396 },
1397 JumpAction {
1398 key: 'K',
1399 key_str: "K",
1400 label: "Keys: Manage SSH keys",
1401 aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
1402 target: JumpActionTarget::Hosts,
1403 },
1404 JumpAction {
1405 key: 'S',
1406 key_str: "S",
1407 label: "Providers: Manage cloud sync",
1408 aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
1409 target: JumpActionTarget::Hosts,
1410 },
1411 JumpAction {
1412 key: 'V',
1413 key_str: "V",
1414 label: "Vault: Sign certificate",
1415 aliases: &["hashicorp", "ssh cert", "vault ssh"],
1416 target: JumpActionTarget::Hosts,
1417 },
1418 JumpAction {
1419 key: 'I',
1420 key_str: "I",
1421 label: "Hosts: Import from known_hosts",
1422 aliases: &["known", "import"],
1423 target: JumpActionTarget::Hosts,
1424 },
1425 JumpAction {
1426 key: 'm',
1427 key_str: "m",
1428 label: "Settings: Switch theme",
1429 aliases: &["color", "appearance", "dark", "light"],
1430 target: JumpActionTarget::Hosts,
1431 },
1432 JumpAction {
1433 key: 'n',
1434 key_str: "n",
1435 label: "Help: What's new",
1436 aliases: &["changelog", "news", "release notes"],
1437 target: JumpActionTarget::Hosts,
1438 },
1439 JumpAction {
1440 key: 'r',
1441 key_str: "r",
1442 label: "Snippets: Run snippet",
1443 aliases: &["execute", "command"],
1444 target: JumpActionTarget::Hosts,
1445 },
1446 JumpAction {
1447 key: 'R',
1448 key_str: "R",
1449 label: "Snippets: Run on all visible",
1450 aliases: &["batch", "execute all"],
1451 target: JumpActionTarget::Hosts,
1452 },
1453 JumpAction {
1454 key: 'p',
1455 key_str: "p",
1456 label: "Hosts: Ping host",
1457 aliases: &["health", "check"],
1458 target: JumpActionTarget::Hosts,
1459 },
1460 JumpAction {
1461 key: 'P',
1462 key_str: "P",
1463 label: "Hosts: Ping all hosts",
1464 aliases: &["health all"],
1465 target: JumpActionTarget::Hosts,
1466 },
1467 JumpAction {
1468 key: '!',
1469 key_str: "!",
1470 label: "Hosts: Show down only",
1471 aliases: &["filter offline", "down only"],
1472 target: JumpActionTarget::Hosts,
1473 },
1474 JumpAction {
1478 key: 'T',
1479 key_str: "T",
1480 label: "Tunnels: Manage tunnels",
1481 aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
1482 target: JumpActionTarget::Hosts,
1483 },
1484 JumpAction {
1485 key: 'a',
1486 key_str: "a",
1487 label: "Tunnels: Add tunnel",
1488 aliases: &["new tunnel", "create tunnel", "forward"],
1489 target: JumpActionTarget::Tunnels,
1490 },
1491 JumpAction {
1492 key: 'e',
1493 key_str: "e",
1494 label: "Tunnels: Edit tunnel",
1495 aliases: &["modify tunnel"],
1496 target: JumpActionTarget::Tunnels,
1497 },
1498 JumpAction {
1499 key: 'd',
1500 key_str: "d",
1501 label: "Tunnels: Delete tunnel",
1502 aliases: &["remove tunnel"],
1503 target: JumpActionTarget::Tunnels,
1504 },
1505 JumpAction {
1506 key: 's',
1507 key_str: "s",
1508 label: "Tunnels: Sort",
1509 aliases: &["order tunnels"],
1510 target: JumpActionTarget::Tunnels,
1511 },
1512 JumpAction {
1513 key: 'R',
1514 key_str: "R",
1515 label: "Containers: Refresh all hosts",
1516 aliases: &["reload containers", "fetch", "rescan"],
1517 target: JumpActionTarget::Containers,
1518 },
1519 JumpAction {
1520 key: 's',
1521 key_str: "s",
1522 label: "Containers: Cycle sort",
1523 aliases: &["order containers", "sort by host", "sort by name"],
1524 target: JumpActionTarget::Containers,
1525 },
1526 JumpAction {
1527 key: 'v',
1528 key_str: "v",
1529 label: "Containers: Toggle detail panel",
1530 aliases: &["show details", "hide details", "compact view"],
1531 target: JumpActionTarget::Containers,
1532 },
1533 JumpAction {
1537 key: 'c',
1538 key_str: "c",
1539 label: "Keys: Copy public key",
1540 aliases: &["yank", "clipboard", "pubkey"],
1541 target: JumpActionTarget::Keys,
1542 },
1543 JumpAction {
1544 key: 'p',
1545 key_str: "p",
1546 label: "Keys: Push to host",
1547 aliases: &["install", "ssh-copy-id", "deploy", "upload"],
1548 target: JumpActionTarget::Keys,
1549 },
1550 JumpAction {
1551 key: 'V',
1552 key_str: "V",
1553 label: "Keys: Sign Vault SSH certificate",
1554 aliases: &["vault", "renew cert", "sign"],
1555 target: JumpActionTarget::Keys,
1556 },
1557];
1558
1559pub const PALETTE_PER_SECTION_CAP: usize = 32;
1564
1565pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
1568 if let Some((prefix, rest)) = query.split_once(':') {
1569 let scope = match prefix.trim() {
1570 "user" => Some(QueryScope::User),
1571 "host" => Some(QueryScope::Hostname),
1572 "proxy" => Some(QueryScope::ProxyJump),
1573 "vault" => Some(QueryScope::VaultSsh),
1574 "tag" => Some(QueryScope::Tag),
1575 _ => None,
1576 };
1577 if scope.is_some() {
1578 return (scope, rest.trim_start());
1579 }
1580 }
1581 (None, query)
1582}
1583
1584#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1585pub enum QueryScope {
1586 User,
1587 Hostname,
1588 ProxyJump,
1589 VaultSsh,
1590 Tag,
1591}
1592
1593fn preview(s: &str, max: usize) -> String {
1595 let s = s.replace('\n', " ");
1596 let chars: Vec<char> = s.chars().collect();
1597 if chars.len() <= max {
1598 s
1599 } else {
1600 let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
1601 out.push_str("...");
1602 out
1603 }
1604}
1605
1606fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
1611 let scope = scope?;
1612 match (hit, scope) {
1613 (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
1614 (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
1615 Some(vec![&h.hostname])
1616 }
1617 (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
1618 Some(vec![&h.proxy_jump])
1619 }
1620 (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
1621 (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
1622 _ => None,
1624 }
1625}
1626
1627pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
1632 if query.is_empty() {
1633 return None;
1634 }
1635 let q = query.to_lowercase();
1636 let alias_hit = host.alias.to_lowercase().contains(&q);
1637 let hostname_hit = host.hostname.to_lowercase().contains(&q);
1638 if alias_hit || hostname_hit {
1639 return None;
1640 }
1641 if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
1642 return Some(MatchSource::User);
1643 }
1644 if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
1645 return Some(MatchSource::ProxyJump);
1646 }
1647 if let Some(role) = &host.vault_ssh {
1648 if role.to_lowercase().contains(&q) {
1649 return Some(MatchSource::VaultSsh);
1650 }
1651 }
1652 if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
1653 return Some(MatchSource::IdentityFile);
1654 }
1655 None
1656}
1657
1658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1659pub enum MatchSource {
1660 User,
1661 ProxyJump,
1662 VaultSsh,
1663 IdentityFile,
1664}
1665
1666fn kind_rank(k: SourceKind) -> u8 {
1667 match k {
1668 SourceKind::Host => 0,
1669 SourceKind::Tunnel => 1,
1670 SourceKind::Container => 2,
1671 SourceKind::Snippet => 3,
1672 SourceKind::Action => 4,
1673 }
1674}
1675
1676fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
1681 if let Some(target) = prior {
1682 if let Some(idx) = hits.iter().position(|h| &h.identity() == target) {
1683 return idx;
1684 }
1685 }
1686 fallback.min(hits.len().saturating_sub(1))
1687}
1688
1689impl JumpAction {
1690 #[cfg(test)]
1691 pub fn all() -> &'static [JumpAction] {
1692 ALL_JUMP_ACTIONS
1693 }
1694
1695 pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
1699 ALL_JUMP_ACTIONS
1700 }
1701}
1702
1703#[cfg(test)]
1704mod tests;