1use crossterm::event::KeyModifiers;
2use ratatui::widgets::ListState;
3
4use crate::history::ConnectionHistory;
5use crate::ssh_config::model::SshConfigFile;
6
7pub(crate) fn contains_ci(haystack: &str, needle: &str) -> bool {
13 if needle.is_empty() {
14 return true;
15 }
16 if haystack.is_ascii() && needle.is_ascii() {
17 return haystack
18 .as_bytes()
19 .windows(needle.len())
20 .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()));
21 }
22 let needle_lower: Vec<char> = needle.chars().map(|c| c.to_ascii_lowercase()).collect();
24 let haystack_chars: Vec<char> = haystack.chars().collect();
25 haystack_chars.windows(needle_lower.len()).any(|window| {
26 window
27 .iter()
28 .zip(needle_lower.iter())
29 .all(|(h, n)| h.to_ascii_lowercase() == *n)
30 })
31}
32
33pub(super) fn eq_ci(a: &str, b: &str) -> bool {
35 a.eq_ignore_ascii_case(b)
36}
37
38mod baselines;
39mod container_state;
40mod containers_overview;
41mod display_list;
42mod file_browser_state;
43mod form_state;
44mod forms;
45mod groups;
46mod host_state;
47mod hosts;
48pub(crate) use hosts::migrate_renames_persistent_state;
49pub(crate) mod jump;
50mod key_push_state;
51mod keys_state;
52pub(crate) mod password_prompt;
53mod pickers;
54pub(crate) mod ping;
55mod provider_state;
56mod reload_state;
57mod screen;
58mod search;
59mod security_key_pins;
60mod selection;
61mod snippet_state;
62mod status_state;
63mod tag_state;
64mod tunnel_state;
65mod ui_state;
66mod update;
67mod vault;
68
69pub use baselines::{FormBaseline, ProviderFormBaseline, SnippetFormBaseline, TunnelFormBaseline};
70pub use container_state::{ContainerSession, ContainerState, LogsView};
71pub use containers_overview::{
72 BulkConfirmContext, BulkConfirmKind, ContainerActionRequest, ContainerExecRequest,
73 ContainerLogsRequest, ContainersOverviewState, ContainersSortMode, InspectCacheEntry,
74 LIST_CACHE_TTL_SECS, LOGS_TAIL, LogsCacheEntry, REFRESH_MAX_PARALLEL, RefreshBatch,
75 RefreshQueueItem,
76};
77pub use file_browser_state::FileBrowserState;
78pub use form_state::FormState;
79pub(crate) use forms::char_to_byte_pos;
80pub use forms::{
81 FormField, HostForm, ProviderFormField, ProviderFormFields, SnippetForm, SnippetFormField,
82 SnippetHostOutput, SnippetOutputState, SnippetParamFormState, TunnelForm, TunnelFormField,
83};
84pub use host_state::{
85 DeletedHost, GroupBy, HostListItem, HostState, ProxyJumpCandidate, SortMode, ViewMode,
86 health_summary_spans, health_summary_spans_for,
87};
88pub use key_push_state::{KeyPushPrompt, KeyPushState};
89pub use keys_state::KeysState;
90pub use password_prompt::{
91 PASSWORD_MAX_CHARS, PasswordPromptField, PasswordPromptState, PendingRetry, SessionPasswords,
92 source_allows_prompt,
93};
94pub use ping::{
95 PingState, PingStatus, classify_ping, ping_sort_key, propagate_ping_to_dependents, status_glyph,
96};
97pub use provider_state::{
98 LabelMigrationField, PendingLabelMigration, PendingPurge, ProviderRow, ProviderState,
99 SyncRecord,
100};
101pub(crate) use reload_state::config_changed;
102pub use reload_state::{ConflictState, ReloadState};
103pub use screen::{ContainerLogsSearch, Screen, StackMember, TopPage, WhatsNewState};
104pub use search::SearchState;
105pub use security_key_pins::{PinOutcome, PinTrial, RunEnd, SecurityKeyPins};
106pub use snippet_state::{SnippetHostPick, SnippetHostPickPurpose, SnippetState};
107pub use status_state::{MessageClass, StatusCenter, StatusMessage};
108pub use tag_state::{
109 BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, TagState,
110 select_display_tags,
111};
112pub use tunnel_state::{TunnelExit, TunnelSortMode, TunnelState};
113pub use ui_state::UiSelection;
114pub use update::UpdateState;
115pub use vault::VaultState;
116
117impl Drop for App {
119 fn drop(&mut self) {
120 for (alias, mut tunnel) in self.tunnels.active.drain() {
121 if let Err(e) = tunnel.child.kill() {
122 log::debug!("[external] Failed to kill tunnel for {alias} on shutdown: {e}");
123 }
124 let _ = tunnel.child.wait();
125 }
126 if let Some(handle) = self.vault.cancel_signing_run() {
130 let _ = handle.join();
131 }
132 self.keys.push.shutdown();
136 }
137}
138
139pub struct App {
141 pub screen: Screen,
144 pub top_page: TopPage,
147 pub running: bool,
149 pub(crate) hosts_state: HostState,
151
152 pub(crate) status_center: StatusCenter,
155 pub(crate) ui: UiSelection,
157 pub(crate) search: SearchState,
159 pub(crate) reload: ReloadState,
161 pub(crate) conflict: ConflictState,
163
164 pub(crate) keys: KeysState,
166
167 pub(crate) tags: TagState,
169
170 pub(crate) forms: FormState,
172
173 pub(crate) history: ConnectionHistory,
175
176 pub(crate) providers: ProviderState,
178
179 pub(crate) ping: PingState,
181
182 pub(crate) vault: VaultState,
184
185 pub(crate) tunnels: TunnelState,
187
188 pub(crate) snippets: SnippetState,
190
191 pub(crate) update: UpdateState,
193
194 pub(crate) bw_session: Option<String>,
196
197 pub(crate) session_passwords: SessionPasswords,
200 pub(crate) security_key_pins: SecurityKeyPins,
203 pub(crate) ssh_allows_security_key_pins: std::cell::OnceCell<bool>,
205 pub(crate) password_prompt: Option<PasswordPromptState>,
207
208 pub(crate) file_browser_state: FileBrowserState,
211 pub(crate) file_browser_session: Option<crate::file_browser::FileBrowserSession>,
213
214 pub(crate) container_state: ContainerState,
217 pub(crate) container_session: Option<ContainerSession>,
219 pub(crate) containers_overview: ContainersOverviewState,
221
222 pub demo_mode: bool,
224
225 pub(crate) env: std::sync::Arc<crate::runtime::env::Env>,
229
230 pub(crate) jump: Option<JumpState>,
232}
233
234fn ssh_version_gate_for_build() -> std::cell::OnceCell<bool> {
237 let gate = std::cell::OnceCell::new();
238 #[cfg(test)]
239 let _ = gate.set(true);
240 gate
241}
242
243impl App {
244 pub fn new(config: SshConfigFile) -> Self {
248 #[cfg(test)]
249 let env = std::sync::Arc::new(crate::runtime::env::Env::sandboxed());
250 #[cfg(not(test))]
251 let env = std::sync::Arc::new(crate::runtime::env::Env::from_process());
252 Self::with_env(config, env)
253 }
254
255 pub fn with_env(config: SshConfigFile, env: std::sync::Arc<crate::runtime::env::Env>) -> Self {
259 let hosts = config.host_entries();
260 let patterns = config.pattern_entries();
261 let display_list = Self::build_display_list_from(&config, &hosts, &patterns);
262
263 let initial_selection = display_list.iter().position(|item| {
264 matches!(
265 item,
266 HostListItem::Host { .. } | HostListItem::Pattern { .. }
267 )
268 });
269
270 let reload = ReloadState::from_config(&env, &config);
271 let hosts_state = HostState::from_config(config, hosts, patterns, display_list);
272
273 Self {
274 screen: Screen::HostList,
275 top_page: TopPage::default(),
276 running: true,
277 hosts_state,
278 status_center: StatusCenter::default(),
279 ui: UiSelection::new_with_initial_selection(initial_selection),
280 search: SearchState::default(),
281 reload,
282 conflict: ConflictState::default(),
283 keys: KeysState {
284 list: Vec::new(),
285 list_state: ratatui::widgets::ListState::default(),
286 activity: crate::key_activity::KeyActivityLog::load(env.paths()),
287 push: KeyPushState::default(),
288 },
289 tags: TagState::default(),
290 forms: FormState::default(),
291 history: ConnectionHistory::load(env.paths()),
292 providers: ProviderState::load(env.paths()),
293 ping: PingState::from_preferences(env.paths()),
294 vault: VaultState::default(),
295 tunnels: TunnelState::default(),
296 snippets: SnippetState::with_store_loaded(env.paths()),
297 update: UpdateState::with_current_hint(&env),
298 bw_session: None,
299 session_passwords: SessionPasswords::new(),
300 security_key_pins: SecurityKeyPins::default(),
301 ssh_allows_security_key_pins: ssh_version_gate_for_build(),
302 password_prompt: None,
303 file_browser_state: FileBrowserState::default(),
304 file_browser_session: None,
305 container_state: ContainerState {
306 cache: crate::containers::load_container_cache(env.paths()),
307 ..ContainerState::default()
308 },
309 container_session: None,
310 containers_overview: ContainersOverviewState::default(),
311 demo_mode: false,
312 env,
313 jump: None,
314 }
315 }
316
317 pub(crate) fn env(&self) -> &crate::runtime::env::Env {
319 &self.env
320 }
321
322 pub fn record_key_use(&mut self, alias: &str, now: u64) {
328 let paths = self.env.paths().cloned();
329 crate::key_activity::record_and_flush(&mut self.keys.activity, alias, now, paths.as_ref());
330 }
331
332 pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
336 self.hosts_state
337 .list
338 .iter()
339 .map(|h| h.alias.clone())
340 .collect()
341 }
342
343 pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
349 let new_aliases: Vec<String> = self
350 .hosts_state
351 .list
352 .iter()
353 .filter(|h| !before_aliases.contains(&h.alias))
354 .map(|h| h.alias.clone())
355 .collect();
356 for alias in new_aliases {
357 self.container_state.queue_fetch(alias);
358 }
359 }
360
361 pub fn reload_hosts(&mut self) {
373 let had_pending_vault_write = self.vault.pending_config_write;
374 let mut flushed_vault_write = false;
387 if self.vault.pending_config_write && !self.is_form_open() {
388 if self.external_config_changed() {
389 self.notify_error(
390 crate::messages::vault_config_skipped_external_change().to_string(),
391 );
392 log::warn!(
393 "[config] reload_hosts: skipping deferred vault write. external config changed"
394 );
395 } else {
396 match self.hosts_state.ssh_config.write() {
397 Ok(()) => flushed_vault_write = true,
398 Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
399 }
400 }
401 }
402 self.vault.pending_config_write = false;
405 log::debug!(
406 "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
407 );
408 let had_search = self.search.query.take();
409 let selected_alias = self
410 .selected_host()
411 .map(|h| h.alias.clone())
412 .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
413
414 self.tunnels.summaries_cache.clear();
415 self.hosts_state.render_cache.invalidate();
416 self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
417 self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
418
419 {
424 let valid_aliases: std::collections::HashSet<&str> = self
425 .hosts_state
426 .list
427 .iter()
428 .map(|h| h.alias.as_str())
429 .collect();
430
431 self.vault.prune_orphans(&valid_aliases);
432
433 if self.container_state.prune_orphans(&valid_aliases) {
438 crate::containers::save_container_cache(
439 self.env().paths(),
440 self.container_state.cache(),
441 );
442 }
443
444 let valid_container_ids: std::collections::HashSet<String> = self
448 .container_state
449 .cache()
450 .values()
451 .flat_map(|e| e.containers.iter().map(|c| c.id.clone()))
452 .collect();
453 self.containers_overview
454 .prune_by_container_ids(&valid_container_ids);
455
456 if self.containers_overview.prune_orphans(&valid_aliases)
460 && let Err(e) = crate::preferences::save_containers_collapsed_hosts(
461 self.env().paths(),
462 self.containers_overview.collapsed_hosts(),
463 )
464 {
465 log::warn!("[config] failed to save collapsed_hosts after prune: {e}");
466 }
467
468 self.file_browser_state.prune_orphans(&valid_aliases);
469 self.tunnels.prune_orphans(&valid_aliases);
470 self.ping.prune_orphans(&valid_aliases);
471
472 let before = self.session_passwords.len();
476 self.session_passwords
477 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
478 if self.session_passwords.len() != before {
479 log::debug!(
480 "[purple] reload_hosts: dropped {} session password(s) for hosts that are gone",
481 before - self.session_passwords.len()
482 );
483 }
484 if self.security_key_pins.prune_aliases(&valid_aliases) {
485 log::debug!(
486 "[purple] reload_hosts: dropped a security key PIN trial for a host that is gone"
487 );
488 }
489 }
490
491 if self.hosts_state.sort_mode == SortMode::Original
492 && matches!(self.hosts_state.group_by, GroupBy::None)
493 {
494 self.hosts_state.display_list = Self::build_display_list_from(
495 &self.hosts_state.ssh_config,
496 &self.hosts_state.list,
497 &self.hosts_state.patterns,
498 );
499 } else {
500 self.apply_sort();
501 }
502
503 if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
505 self.set_screen(Screen::HostList);
506 self.forms.bulk_tag_editor = BulkTagEditorState::default();
507 }
508
509 self.hosts_state.multi_select.clear();
511
512 if let Some(query) = had_search {
514 self.search.query = Some(query);
515 self.apply_filter();
516 } else {
517 self.search.query = None;
518 self.search.filtered_indices.clear();
519 self.search.filtered_pattern_indices.clear();
520 if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
522 self.ui.list_state.select(None);
523 } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
524 matches!(
525 item,
526 HostListItem::Host { .. } | HostListItem::Pattern { .. }
527 )
528 }) {
529 let current = self.ui.list_state.selected().unwrap_or(0);
530 if current >= self.hosts_state.display_list.len()
531 || !matches!(
532 self.hosts_state.display_list.get(current),
533 Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
534 )
535 {
536 self.ui.list_state.select(Some(pos));
537 }
538 } else {
539 self.ui.list_state.select(None);
540 }
541 }
542
543 if let Some(alias) = selected_alias {
545 self.select_host_by_alias(&alias);
546 }
547
548 log::debug!(
549 "[config] reload_hosts: hosts={} patterns={} display_items={}",
550 self.hosts_state.list.len(),
551 self.hosts_state.patterns.len(),
552 self.hosts_state.display_list.len(),
553 );
554 }
555
556 pub fn refresh_cert_cache(&mut self, alias: &str) {
567 if crate::demo_flag::is_demo() {
568 return;
569 }
570 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
571 self.vault.cert_cache.remove(alias);
572 return;
573 };
574 let role_some = crate::vault_ssh::resolve_vault_role(
575 host.vault_ssh.as_deref(),
576 host.provider.as_deref(),
577 host.provider_label.as_deref(),
578 &self.providers.config,
579 )
580 .is_some();
581 if !role_some {
582 self.vault.cert_cache.remove(alias);
583 return;
584 }
585 let cert_path = match crate::vault_ssh::resolve_cert_path(
586 self.env().paths(),
587 alias,
588 &host.certificate_file,
589 ) {
590 Ok(p) => p,
591 Err(_) => {
592 self.vault.cert_cache.remove(alias);
593 return;
594 }
595 };
596 let status = crate::vault_ssh::check_cert_validity(self.env(), &cert_path);
597 let mtime = std::fs::metadata(&cert_path)
598 .ok()
599 .and_then(|m| m.modified().ok());
600 self.vault.cert_cache.insert(
601 alias.to_string(),
602 (std::time::Instant::now(), status, mtime),
603 );
604 }
605
606 #[cfg(test)]
613 pub fn sorted_provider_names(&self) -> Vec<String> {
614 self.providers.sorted_names()
615 }
616
617 pub fn is_form_open(&self) -> bool {
619 matches!(
620 self.screen,
621 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
622 )
623 }
624
625 pub fn open_jump(&mut self, mode: JumpMode) {
628 log::debug!("[purple] jump: open mode={:?}", mode);
629 let mut state = JumpState::for_mode(mode);
630 let recents_file = jump::load_recents(self.env.paths());
631 state.recents = self.resolve_recents(&recents_file);
632 self.jump = Some(state);
633 self.recompute_jump_hits();
634 }
635
636 pub(crate) fn close_jump(&mut self) {
640 self.jump = None;
641 }
642
643 fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
646 let mode = self
647 .jump
648 .as_ref()
649 .map(|p| p.mode)
650 .unwrap_or(JumpMode::Hosts);
651 let mut out = Vec::with_capacity(file.entries.len());
652 for entry in &file.entries {
653 if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
654 out.push(hit);
655 }
656 }
657 out
658 }
659
660 #[cfg(test)]
664 pub(crate) fn resolve_recent_ref_for_test(
665 &self,
666 r: &RecentRef,
667 mode: JumpMode,
668 ) -> Option<JumpHit> {
669 self.resolve_recent_ref(r, mode)
670 }
671
672 fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
673 match r.kind {
674 SourceKind::Action => {
675 let key_char = r.key.chars().next()?;
676 let actions = JumpAction::for_mode(mode);
677 actions
678 .iter()
679 .find(|a| a.key == key_char)
680 .copied()
681 .map(JumpHit::Action)
682 }
683 SourceKind::Host => {
684 let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
685 Some(JumpHit::Host(HostHit {
686 alias: host.alias.clone(),
687 hostname: host.hostname.clone(),
688 tags: host.tags.clone(),
689 provider: host.provider.clone(),
690 user: host.user.clone(),
691 identity_file: host.identity_file.clone(),
692 proxy_jump: host.proxy_jump.clone(),
693 vault_ssh: host.vault_ssh.clone(),
694 }))
695 }
696 SourceKind::Tunnel => {
697 let (alias, port_str) = r.key.split_once(':')?;
698 let port: u16 = port_str.parse().ok()?;
699 let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
700 let rule = rules.iter().find(|r| r.bind_port == port)?;
701 Some(JumpHit::Tunnel(TunnelHit {
702 alias: alias.to_string(),
703 bind_port: rule.bind_port,
704 bind_port_str: rule.bind_port.to_string(),
705 destination: rule.display(),
706 active: self.tunnels.active.contains_key(alias),
707 }))
708 }
709 SourceKind::Container => {
710 let (alias, name) = r.key.split_once('/')?;
711 let entry = self.container_state.cache.get(alias)?;
712 let info = entry.containers.iter().find(|c| c.names == name)?;
713 Some(JumpHit::Container(ContainerHit {
714 alias: alias.to_string(),
715 container_name: info.names.clone(),
716 container_id: info.id.clone(),
717 state: info.state.clone(),
718 }))
719 }
720 SourceKind::Snippet => {
721 let snippet = self.snippets.store.get(&r.key)?;
722 Some(JumpHit::Snippet(SnippetHit {
723 name: snippet.name.clone(),
724 command_preview: preview(&snippet.command, 40),
725 }))
726 }
727 }
728 }
729
730 pub fn recompute_jump_hits(&mut self) {
736 let Some(mut state) = self.jump.take() else {
737 return;
738 };
739 let prior_identity = state
743 .visible_hits()
744 .get(state.selected)
745 .map(|h| h.identity());
746
747 let candidates = self.collect_jump_candidates(state.mode);
748 if state.query.is_empty() {
749 state.hits = candidates;
750 state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
751 self.jump = Some(state);
752 return;
753 }
754
755 let (scope, effective_query) = parse_query_scope(&state.query);
760
761 use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
762 use nucleo_matcher::{Config, Matcher, Utf32Str};
763 let matcher_state = state
764 .matcher
765 .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
766 let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
767 let mut buf: Vec<char> = Vec::new();
768 let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
769 for hit in candidates {
770 let mut best: u32 = 0;
771 let scoped_haystacks = scoped_haystacks_for(&hit, scope);
775 let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
776 hs
777 } else {
778 hit.haystacks()
779 };
780 for haystack in haystacks {
781 buf.clear();
782 let chars = Utf32Str::new(haystack, &mut buf);
783 if let Some(score) = pattern.score(chars, matcher_state) {
784 best = best.max(score);
785 }
786 }
787 if let JumpHit::Action(a) = &hit {
797 let mode_match = matches!(
798 (state.mode, a.target),
799 (JumpMode::Hosts, JumpActionTarget::Hosts)
800 | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
801 | (JumpMode::Containers, JumpActionTarget::Containers)
802 | (JumpMode::Keys, JumpActionTarget::Keys)
803 );
804 let single = effective_query.chars().next();
805 let exact_hotkey = effective_query.chars().count() == 1
806 && single
807 .map(|c| c.eq_ignore_ascii_case(&a.key))
808 .unwrap_or(false);
809 let bump = if exact_hotkey && mode_match {
810 20_000
811 } else if exact_hotkey {
812 10_000
813 } else if mode_match && best > 0 {
814 1_000
819 } else {
820 0
821 };
822 if bump > 0 {
823 best = best.saturating_add(bump);
824 }
825 }
826 let floor = match &hit {
830 JumpHit::Action(_) => jump::PALETTE_ACTION_FLOOR,
831 _ => 1,
832 };
833 if best >= floor {
834 scored.push((hit, best));
835 }
836 }
837 scored.sort_by(|a, b| {
840 b.1.cmp(&a.1)
841 .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
842 });
843 let mut per_kind: [usize; 5] = [0; 5];
846 let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
847 for (hit, _) in scored {
848 let slot = kind_rank(hit.kind()) as usize;
849 if per_kind[slot] < PALETTE_PER_SECTION_CAP {
850 per_kind[slot] += 1;
851 filtered.push(hit);
852 }
853 }
854 state.hits = filtered;
855 let display = state.visible_hits();
864 let top_display = state
865 .hits
866 .first()
867 .map(|h| h.kind())
868 .and_then(|k| display.iter().position(|h| h.kind() == k))
869 .unwrap_or(0);
870 state.selected = restore_selection(&display, prior_identity.as_ref(), top_display);
871 log::debug!(
872 "[purple] jump: recompute selected={} of {} hits (top_display={})",
873 state.selected,
874 state.hits.len(),
875 top_display
876 );
877 self.jump = Some(state);
878 }
879
880 fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
881 let mut out: Vec<JumpHit> = Vec::new();
882 for h in &self.hosts_state.list {
884 out.push(JumpHit::Host(HostHit {
885 alias: h.alias.clone(),
886 hostname: h.hostname.clone(),
887 tags: h.tags.clone(),
888 provider: h.provider.clone(),
889 user: h.user.clone(),
890 identity_file: h.identity_file.clone(),
891 proxy_jump: h.proxy_jump.clone(),
892 vault_ssh: h.vault_ssh.clone(),
893 }));
894 }
895 for h in &self.hosts_state.list {
897 let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
898 for rule in rules {
899 out.push(JumpHit::Tunnel(TunnelHit {
900 alias: h.alias.clone(),
901 bind_port: rule.bind_port,
902 bind_port_str: rule.bind_port.to_string(),
903 destination: rule.display(),
904 active: self.tunnels.active.contains_key(&h.alias),
905 }));
906 }
907 }
908 for (alias, entry) in &self.container_state.cache {
911 for info in &entry.containers {
912 out.push(JumpHit::Container(ContainerHit {
913 alias: alias.clone(),
914 container_name: info.names.clone(),
915 container_id: info.id.clone(),
916 state: info.state.clone(),
917 }));
918 }
919 }
920 for snippet in &self.snippets.store.snippets {
922 out.push(JumpHit::Snippet(SnippetHit {
923 name: snippet.name.clone(),
924 command_preview: preview(&snippet.command, 40),
925 }));
926 }
927 for a in JumpAction::for_mode(mode) {
929 out.push(JumpHit::Action(*a));
930 }
931 out
932 }
933
934 pub fn record_jump_hit(&mut self, hit: &JumpHit) {
940 if self.demo_mode {
941 log::debug!("[purple] jump: record skipped (demo mode)");
942 return;
943 }
944 let paths = self.env.paths().cloned();
945 let mut file = jump::load_recents(paths.as_ref());
946 jump::touch_recent(&mut file, hit.identity());
947 if let Err(e) = jump::save_recents(&file, paths.as_ref()) {
948 log::warn!("[purple] failed to save recents: {e}");
949 }
950 }
951
952 pub(crate) fn open_file_browser(&mut self, session: crate::file_browser::FileBrowserSession) {
956 let alias = session.alias.clone();
957 self.file_browser_session = Some(session);
958 self.set_screen(Screen::FileBrowser { alias });
959 }
960
961 pub(crate) fn close_file_browser(&mut self) {
965 if let Some(fb) = self.file_browser_session.take() {
966 self.abandon_security_key_trial(
967 crate::security_key::PinFlow::FileBrowser,
968 Some(&fb.alias),
969 );
970 self.file_browser_state
971 .host_paths
972 .insert(fb.alias, (fb.local_path, fb.remote_path));
973 }
974 self.set_screen(Screen::HostList);
975 }
976
977 pub fn flush_pending_vault_write(&mut self) -> bool {
980 if !self.vault.pending_config_write || self.is_form_open() {
981 return false;
982 }
983 self.reload_hosts();
985 true
986 }
987
988 pub fn post_init(&mut self) {
992 let outcome = crate::onboarding::evaluate(self.env().paths());
993 if let Some(text) = outcome.upgrade_toast {
994 self.enqueue_sticky_toast(text);
995 }
996 self.scan_keys();
1000 }
1001
1002 fn enqueue_sticky_toast(&mut self, text: String) {
1003 log::debug!("[purple] enqueue sticky toast: {}", text);
1004 let msg = StatusMessage {
1005 text,
1006 class: MessageClass::Success,
1007 tick_count: 0,
1008 sticky: true,
1009 created_at: std::time::Instant::now(),
1010 };
1011 self.status_center.toast = Some(msg);
1012 }
1013
1014 pub fn notify(&mut self, text: impl Into<String>) {
1016 self.status_center.set_status(text, false);
1017 }
1018
1019 pub fn notify_error(&mut self, text: impl Into<String>) {
1021 self.status_center.set_status(text, true);
1022 }
1023
1024 pub fn notify_background(&mut self, text: impl Into<String>) {
1026 self.status_center.set_background_status(text, false);
1027 }
1028
1029 pub fn notify_background_error(&mut self, text: impl Into<String>) {
1031 self.status_center.set_background_status(text, true);
1032 }
1033
1034 pub fn notify_warning(&mut self, text: impl Into<String>) {
1046 let msg = StatusMessage {
1047 text: text.into(),
1048 class: MessageClass::Warning,
1049 tick_count: 0,
1050 sticky: false,
1051 created_at: std::time::Instant::now(),
1052 };
1053 log::debug!("[purple] toast <- Warning: {}", msg.text);
1054 self.status_center.push_toast(msg);
1055 }
1056
1057 pub fn notify_progress(&mut self, text: impl Into<String>) {
1059 self.status_center.set_sticky_status(text, false);
1060 }
1061
1062 pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
1064 self.status_center.set_sticky_status(text, true);
1065 }
1066
1067 pub fn notify_info(&mut self, text: impl Into<String>) {
1069 self.status_center.set_info_status(text);
1070 }
1071
1072 pub(crate) fn clear_status(&mut self) {
1077 self.status_center.clear_status();
1078 }
1079
1080 pub fn tick_status(&mut self) {
1087 if !self.providers.syncing.is_empty() {
1089 return;
1090 }
1091 if let Some(ref status) = self.status_center.status {
1092 if status.sticky {
1093 return;
1094 }
1095 let timeout_ms = status.timeout_ms();
1096 if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
1097 {
1098 log::debug!("[purple] footer status expired: {}", status.text);
1099 self.status_center.status = None;
1100 }
1101 }
1102 }
1103
1104 pub fn tick_toast(&mut self) {
1106 self.status_center.tick_toast();
1107 }
1108
1109 pub fn check_config_changed(&mut self) {
1113 if matches!(
1114 self.screen,
1115 Screen::AddHost
1116 | Screen::EditHost { .. }
1117 | Screen::ProviderForm { .. }
1118 | Screen::TunnelList { .. }
1119 | Screen::TunnelForm { .. }
1120 | Screen::HostDetail { .. }
1121 | Screen::SnippetPicker
1122 | Screen::SnippetForm
1123 | Screen::SnippetOutput
1124 | Screen::SnippetParamForm
1125 | Screen::FileBrowser { .. }
1126 | Screen::Containers { .. }
1127 | Screen::ConfirmDelete { .. }
1128 | Screen::ConfirmHostKeyReset { .. }
1129 | Screen::ConfirmHostKeyTrust { .. }
1130 | Screen::PasswordPrompt
1131 | Screen::ConfirmPurgeStale
1132 | Screen::ConfirmImport { .. }
1133 | Screen::ConfirmVaultSign
1134 | Screen::TagPicker
1135 | Screen::BulkTagEditor
1136 | Screen::ThemePicker
1137 | Screen::WhatsNew(_)
1138 ) || self.tags.input.is_some()
1139 {
1140 return;
1141 }
1142 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1143 let changed = current_mtime != self.reload.last_modified
1144 || self
1145 .reload
1146 .include_mtimes
1147 .iter()
1148 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1149 || self
1150 .reload
1151 .include_dir_mtimes
1152 .iter()
1153 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
1154 if changed {
1155 log::debug!(
1156 "[config] check_config_changed: mtime drift detected on {} -> reloading",
1157 self.reload.config_path.display()
1158 );
1159 if let Ok(new_config) =
1160 SshConfigFile::parse_with_env(&self.reload.config_path, &self.env)
1161 {
1162 let before_aliases = self.snapshot_alias_set();
1163 self.hosts_state.ssh_config = new_config;
1164 self.hosts_state.undo_stack.clear();
1166 log::debug!(
1168 "[config] external config change: clearing {} ping result(s) + timestamps",
1169 self.ping.status.len()
1170 );
1171 self.ping.status.clear();
1172 self.ping.last_checked.clear();
1173 self.ping.filter_down_only = false;
1174 self.ping.checked_at = None;
1175 self.reload_hosts();
1176 self.reload.last_modified = current_mtime;
1177 self.reload.include_mtimes =
1178 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1179 self.reload.include_dir_mtimes = reload_state::snapshot_include_dir_mtimes(
1180 &self.env,
1181 &self.hosts_state.ssh_config,
1182 );
1183 let count = self.hosts_state.list.len();
1184 self.notify_background(crate::messages::config_reloaded(count));
1185 self.queue_new_aliases_since(&before_aliases);
1186 }
1187 }
1188 }
1189
1190 pub fn check_keys_changed(&mut self) {
1200 if self.demo_mode {
1201 return;
1202 }
1203 if matches!(
1204 self.screen,
1205 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
1206 ) {
1207 return;
1208 }
1209 let Some(ssh_dir) = self.env().paths().map(crate::runtime::env::Paths::ssh_dir) else {
1210 return;
1211 };
1212 let current_dir_mtime = reload_state::get_mtime(&ssh_dir);
1213 let dir_changed = current_dir_mtime != self.reload.keys_dir_mtime;
1214 let files_changed = self
1215 .reload
1216 .key_file_mtimes
1217 .iter()
1218 .any(|(path, old)| reload_state::get_mtime(path) != *old);
1219 if !dir_changed && !files_changed {
1220 return;
1221 }
1222 log::debug!(
1223 "[purple] check_keys_changed: drift detected on {} (dir={} files={}) -> rescan",
1224 ssh_dir.display(),
1225 dir_changed,
1226 files_changed,
1227 );
1228 let previous = self.keys.list.len();
1229 self.scan_keys();
1230 let after = self.keys.list.len();
1231 if let Some(sel) = self.keys.list_state.selected() {
1234 if sel >= after {
1235 let next = after.checked_sub(1);
1236 self.keys.list_state.select(next);
1237 }
1238 } else if after > 0 {
1239 self.keys.list_state.select(Some(0));
1240 }
1241 if previous != after {
1242 log::debug!(
1243 "[purple] check_keys_changed: rescan {} -> {} keys",
1244 previous,
1245 after
1246 );
1247 }
1248 }
1249
1250 pub fn external_config_changed(&self) -> bool {
1259 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1260 current_mtime != self.reload.last_modified
1261 || self
1262 .reload
1263 .include_mtimes
1264 .iter()
1265 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1266 || self
1267 .reload
1268 .include_dir_mtimes
1269 .iter()
1270 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1271 }
1272
1273 pub fn update_last_modified(&mut self) {
1275 self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
1276 self.reload.include_mtimes =
1277 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1278 self.reload.include_dir_mtimes =
1279 reload_state::snapshot_include_dir_mtimes(&self.env, &self.hosts_state.ssh_config);
1280 }
1281
1282 pub fn has_any_vault_role(&self) -> bool {
1284 for host in &self.hosts_state.list {
1285 if host.vault_ssh.is_some() {
1286 return true;
1287 }
1288 }
1289 for section in &self.providers.config.sections {
1290 if !section.vault_role.is_empty() {
1291 return true;
1292 }
1293 }
1294 false
1295 }
1296
1297 pub fn poll_tunnels(&mut self) -> Vec<TunnelExit> {
1299 self.tunnels.poll()
1300 }
1301
1302 pub fn refresh_tunnel_bind_ports(&mut self) {
1307 let mut ports: Vec<(String, u16, u32)> = Vec::new();
1308 for (alias, tunnel) in &self.tunnels.active {
1309 let pid = tunnel.child.id();
1310 for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
1311 ports.push((alias.clone(), rule.bind_port, pid));
1312 }
1313 }
1314 self.tunnels.set_lsof_ports(ports);
1315 }
1316}
1317
1318pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
1320 if len == 0 {
1321 return;
1322 }
1323 let i = match state.selected() {
1324 Some(i) => {
1325 if forward {
1326 if i >= len - 1 { 0 } else { i + 1 }
1327 } else if i == 0 {
1328 len - 1
1329 } else {
1330 i - 1
1331 }
1332 }
1333 None => 0,
1334 };
1335 state.select(Some(i));
1336}
1337
1338pub(crate) fn apply_bulk_tags(
1343 hosts: &mut HostState,
1344 forms: &mut FormState,
1345) -> Result<BulkTagApplyResult, String> {
1346 if forms.bulk_tag_editor.aliases.is_empty() {
1347 return Err(crate::messages::BULK_TAG_NO_HOSTS_SELECTED.to_string());
1348 }
1349 let aliases = forms.bulk_tag_editor.aliases.clone();
1350 let rows = forms.bulk_tag_editor.rows.clone();
1351 let skipped_set: std::collections::HashSet<&str> = forms
1352 .bulk_tag_editor
1353 .skipped_included
1354 .iter()
1355 .map(|s| s.as_str())
1356 .collect();
1357
1358 let has_pending = rows.iter().any(|r| r.action != BulkTagAction::Leave);
1361 if !has_pending {
1362 return Ok(BulkTagApplyResult {
1363 skipped_included: skipped_set.len(),
1364 ..Default::default()
1365 });
1366 }
1367
1368 let mut changed_hosts: std::collections::HashSet<String> = std::collections::HashSet::new();
1369 let mut added = 0usize;
1370 let mut removed = 0usize;
1371 let mut skipped_included = 0usize;
1372 let mut undo_snapshot: Vec<(String, Vec<String>)> = Vec::new();
1376
1377 for alias in &aliases {
1378 if skipped_set.contains(alias.as_str()) {
1379 skipped_included += 1;
1380 continue;
1381 }
1382 let Some(host) = hosts.list.iter().find(|h| &h.alias == alias) else {
1383 continue;
1384 };
1385 let original_tags = host.tags.clone();
1386 let mut new_tags = original_tags.clone();
1387 let mut host_changed = false;
1388 for row in &rows {
1389 match row.action {
1390 BulkTagAction::Leave => {}
1391 BulkTagAction::AddToAll => {
1392 if !new_tags.iter().any(|t| t == &row.tag) {
1393 new_tags.push(row.tag.clone());
1394 added += 1;
1395 host_changed = true;
1396 }
1397 }
1398 BulkTagAction::RemoveFromAll => {
1399 let before = new_tags.len();
1400 new_tags.retain(|t| t != &row.tag);
1401 if new_tags.len() != before {
1402 removed += 1;
1403 host_changed = true;
1404 }
1405 }
1406 }
1407 }
1408 if host_changed {
1409 let _ = hosts.ssh_config.set_host_tags(alias, &new_tags);
1410 changed_hosts.insert(alias.clone());
1411 undo_snapshot.push((alias.clone(), original_tags));
1412 }
1413 }
1414
1415 if changed_hosts.is_empty() {
1416 return Ok(BulkTagApplyResult {
1417 skipped_included,
1418 ..Default::default()
1419 });
1420 }
1421
1422 let config_backup = hosts.ssh_config.clone();
1425 if let Err(e) = hosts.ssh_config.write() {
1426 log::error!("[purple] bulk tag apply write failed: {e}");
1427 hosts.ssh_config = config_backup;
1428 return Err(format!("Failed to save: {}", e));
1429 }
1430
1431 log::debug!(
1432 "[purple] bulk tag apply: {} hosts, +{} -{}, skipped {}",
1433 changed_hosts.len(),
1434 added,
1435 removed,
1436 skipped_included
1437 );
1438 if !undo_snapshot.is_empty() {
1441 forms.bulk_tag_undo = Some(undo_snapshot);
1442 }
1443
1444 Ok(BulkTagApplyResult {
1445 changed_hosts: changed_hosts.len(),
1446 added,
1447 removed,
1448 skipped_included,
1449 })
1450}
1451
1452pub(crate) fn bulk_tag_cycle_current(ui: &UiSelection, forms: &mut FormState) {
1455 let Some(idx) = ui.bulk_tag_editor_state.selected() else {
1456 return;
1457 };
1458 if let Some(row) = forms.bulk_tag_editor.rows.get_mut(idx) {
1459 row.action = row.action.cycle();
1460 }
1461}
1462
1463pub(crate) fn bulk_tag_commit_new_tag(ui: &mut UiSelection, forms: &mut FormState) {
1467 let Some(input) = forms.bulk_tag_editor.new_tag_input.take() else {
1468 return;
1469 };
1470 forms.bulk_tag_editor.new_tag_cursor = 0;
1471 let tag = input.trim().to_string();
1472 if tag.is_empty() {
1473 return;
1474 }
1475 if let Some(existing) = forms.bulk_tag_editor.rows.iter().position(|r| r.tag == tag) {
1476 forms.bulk_tag_editor.rows[existing].action = BulkTagAction::AddToAll;
1477 ui.bulk_tag_editor_state.select(Some(existing));
1478 return;
1479 }
1480 let row = BulkTagRow {
1481 tag,
1482 initial_count: 0,
1483 action: BulkTagAction::AddToAll,
1484 };
1485 let insert_at = forms.bulk_tag_editor.rows.len();
1486 forms.bulk_tag_editor.rows.push(row);
1487 ui.bulk_tag_editor_state.select(Some(insert_at));
1488}
1489
1490pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
1492 if len == 0 {
1493 return;
1494 }
1495 let current = state.selected().unwrap_or(0);
1496 let next = (current + page_size).min(len - 1);
1497 state.select(Some(next));
1498}
1499
1500pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
1502 if len == 0 {
1503 return;
1504 }
1505 let current = state.selected().unwrap_or(0);
1506 let prev = current.saturating_sub(page_size);
1507 state.select(Some(prev));
1508}
1509
1510pub use jump::{
1514 ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, JumpMode, JumpState, RecentRef,
1515 RecentsFile, SnippetHit, SourceKind, TunnelHit,
1516};
1517
1518#[cfg(test)]
1522pub type PaletteCommand = JumpAction;
1523
1524static ALL_JUMP_ACTIONS: &[JumpAction] = &[
1531 JumpAction {
1532 key: 'a',
1533 key_str: "a",
1534 label: "Hosts: Add host",
1535 aliases: &["new", "create"],
1536 target: JumpActionTarget::Hosts,
1537 modifiers: KeyModifiers::NONE,
1538 },
1539 JumpAction {
1540 key: 'A',
1541 key_str: "A",
1542 label: "Hosts: Add pattern",
1543 aliases: &["new pattern", "wildcard"],
1544 target: JumpActionTarget::Hosts,
1545 modifiers: KeyModifiers::NONE,
1546 },
1547 JumpAction {
1548 key: 'e',
1549 key_str: "e",
1550 label: "Hosts: Edit host",
1551 aliases: &["modify", "change"],
1552 target: JumpActionTarget::Hosts,
1553 modifiers: KeyModifiers::NONE,
1554 },
1555 JumpAction {
1556 key: 'd',
1557 key_str: "d",
1558 label: "Hosts: Delete host",
1559 aliases: &["remove", "rm"],
1560 target: JumpActionTarget::Hosts,
1561 modifiers: KeyModifiers::NONE,
1562 },
1563 JumpAction {
1564 key: 'c',
1565 key_str: "c",
1566 label: "Hosts: Clone host",
1567 aliases: &["duplicate", "copy"],
1568 target: JumpActionTarget::Hosts,
1569 modifiers: KeyModifiers::NONE,
1570 },
1571 JumpAction {
1572 key: 'u',
1573 key_str: "u",
1574 label: "Hosts: Undo delete",
1575 aliases: &["restore"],
1576 target: JumpActionTarget::Hosts,
1577 modifiers: KeyModifiers::NONE,
1578 },
1579 JumpAction {
1580 key: 't',
1581 key_str: "t",
1582 label: "Hosts: Tag host",
1583 aliases: &["label", "category"],
1584 target: JumpActionTarget::Hosts,
1585 modifiers: KeyModifiers::NONE,
1586 },
1587 JumpAction {
1588 key: 'i',
1589 key_str: "i",
1590 label: "Hosts: Show all directives",
1591 aliases: &["raw", "config", "settings"],
1592 target: JumpActionTarget::Hosts,
1593 modifiers: KeyModifiers::NONE,
1594 },
1595 JumpAction {
1596 key: 'y',
1597 key_str: "y",
1598 label: "Clipboard: Copy SSH command",
1599 aliases: &["yank"],
1600 target: JumpActionTarget::Hosts,
1601 modifiers: KeyModifiers::NONE,
1602 },
1603 JumpAction {
1604 key: 'x',
1605 key_str: "x",
1606 label: "Clipboard: Copy config block",
1607 aliases: &["yank config"],
1608 target: JumpActionTarget::Hosts,
1609 modifiers: KeyModifiers::NONE,
1610 },
1611 JumpAction {
1612 key: 'X',
1613 key_str: "X",
1614 label: "Hosts: Purge stale hosts",
1615 aliases: &["clean", "cleanup"],
1616 target: JumpActionTarget::Hosts,
1617 modifiers: KeyModifiers::NONE,
1618 },
1619 JumpAction {
1620 key: 'F',
1621 key_str: "F",
1622 label: "Files: Browse remote files",
1623 aliases: &[
1624 "browse",
1625 "filesystem",
1626 "scp",
1627 "sftp",
1628 "transfer",
1629 "explorer",
1630 "open",
1631 ],
1632 target: JumpActionTarget::Hosts,
1633 modifiers: KeyModifiers::NONE,
1634 },
1635 JumpAction {
1636 key: 'C',
1637 key_str: "C",
1638 label: "Containers: List containers",
1639 aliases: &["docker", "podman", "ps", "open"],
1640 target: JumpActionTarget::Hosts,
1641 modifiers: KeyModifiers::NONE,
1642 },
1643 JumpAction {
1644 key: 'K',
1645 key_str: "K",
1646 label: "Keys: Manage SSH keys",
1647 aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
1648 target: JumpActionTarget::Hosts,
1649 modifiers: KeyModifiers::NONE,
1650 },
1651 JumpAction {
1652 key: 'S',
1653 key_str: "S",
1654 label: "Providers: Manage cloud sync",
1655 aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
1656 target: JumpActionTarget::Hosts,
1657 modifiers: KeyModifiers::NONE,
1658 },
1659 JumpAction {
1660 key: 'V',
1661 key_str: "V",
1662 label: "Vault: Sign certificate",
1663 aliases: &["hashicorp", "ssh cert", "vault ssh"],
1664 target: JumpActionTarget::Hosts,
1665 modifiers: KeyModifiers::NONE,
1666 },
1667 JumpAction {
1668 key: 'I',
1669 key_str: "I",
1670 label: "Hosts: Import from known_hosts",
1671 aliases: &["known", "import"],
1672 target: JumpActionTarget::Hosts,
1673 modifiers: KeyModifiers::NONE,
1674 },
1675 JumpAction {
1676 key: 'm',
1677 key_str: "m",
1678 label: "Settings: Switch theme",
1679 aliases: &["color", "appearance", "dark", "light"],
1680 target: JumpActionTarget::Hosts,
1681 modifiers: KeyModifiers::NONE,
1682 },
1683 JumpAction {
1684 key: 'n',
1685 key_str: "n",
1686 label: "Help: What's new",
1687 aliases: &["changelog", "news", "release notes"],
1688 target: JumpActionTarget::Hosts,
1689 modifiers: KeyModifiers::NONE,
1690 },
1691 JumpAction {
1692 key: 'r',
1693 key_str: "r",
1694 label: "Snippets: Run snippet",
1695 aliases: &["execute", "command"],
1696 target: JumpActionTarget::Hosts,
1697 modifiers: KeyModifiers::NONE,
1698 },
1699 JumpAction {
1700 key: 'R',
1701 key_str: "R",
1702 label: "Snippets: Run on all visible",
1703 aliases: &["batch", "execute all"],
1704 target: JumpActionTarget::Hosts,
1705 modifiers: KeyModifiers::NONE,
1706 },
1707 JumpAction {
1708 key: 'p',
1709 key_str: "p",
1710 label: "Hosts: Ping host",
1711 aliases: &["health", "check"],
1712 target: JumpActionTarget::Hosts,
1713 modifiers: KeyModifiers::NONE,
1714 },
1715 JumpAction {
1716 key: 'P',
1717 key_str: "P",
1718 label: "Hosts: Ping all hosts",
1719 aliases: &["health all"],
1720 target: JumpActionTarget::Hosts,
1721 modifiers: KeyModifiers::NONE,
1722 },
1723 JumpAction {
1724 key: '!',
1725 key_str: "!",
1726 label: "Hosts: Show down only",
1727 aliases: &["filter offline", "down only"],
1728 target: JumpActionTarget::Hosts,
1729 modifiers: KeyModifiers::NONE,
1730 },
1731 JumpAction {
1732 key: 's',
1733 key_str: "s",
1734 label: "Hosts: Sort",
1735 aliases: &["order", "arrange", "sort by"],
1736 target: JumpActionTarget::Hosts,
1737 modifiers: KeyModifiers::NONE,
1738 },
1739 JumpAction {
1740 key: 'g',
1741 key_str: "g",
1742 label: "Hosts: Cycle grouping",
1743 aliases: &["group", "group by", "category"],
1744 target: JumpActionTarget::Hosts,
1745 modifiers: KeyModifiers::NONE,
1746 },
1747 JumpAction {
1748 key: 'v',
1749 key_str: "v",
1750 label: "Hosts: Toggle compact view",
1751 aliases: &["view mode", "detail panel", "compact", "detailed"],
1752 target: JumpActionTarget::Hosts,
1753 modifiers: KeyModifiers::NONE,
1754 },
1755 JumpAction {
1756 key: '#',
1757 key_str: "#",
1758 label: "Hosts: Filter by tag",
1759 aliases: &["tag", "tag filter", "filter tag"],
1760 target: JumpActionTarget::Hosts,
1761 modifiers: KeyModifiers::NONE,
1762 },
1763 JumpAction {
1764 key: ' ',
1765 key_str: " ",
1766 label: "Hosts: Mark host",
1767 aliases: &["select", "multi-select", "toggle mark", "checkbox"],
1768 target: JumpActionTarget::Hosts,
1769 modifiers: KeyModifiers::NONE,
1770 },
1771 JumpAction {
1772 key: 'a',
1773 key_str: "a",
1774 label: "Hosts: Select all visible",
1775 aliases: &["select all", "mark all", "bulk select"],
1776 target: JumpActionTarget::Hosts,
1777 modifiers: KeyModifiers::CONTROL,
1778 },
1779 JumpAction {
1783 key: 'T',
1784 key_str: "T",
1785 label: "Tunnels: Manage tunnels",
1786 aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
1787 target: JumpActionTarget::Hosts,
1788 modifiers: KeyModifiers::NONE,
1789 },
1790 JumpAction {
1791 key: 'a',
1792 key_str: "a",
1793 label: "Tunnels: Add tunnel",
1794 aliases: &["new tunnel", "create tunnel", "forward"],
1795 target: JumpActionTarget::Tunnels,
1796 modifiers: KeyModifiers::NONE,
1797 },
1798 JumpAction {
1799 key: 'e',
1800 key_str: "e",
1801 label: "Tunnels: Edit tunnel",
1802 aliases: &["modify tunnel"],
1803 target: JumpActionTarget::Tunnels,
1804 modifiers: KeyModifiers::NONE,
1805 },
1806 JumpAction {
1807 key: 'd',
1808 key_str: "d",
1809 label: "Tunnels: Delete tunnel",
1810 aliases: &["remove tunnel"],
1811 target: JumpActionTarget::Tunnels,
1812 modifiers: KeyModifiers::NONE,
1813 },
1814 JumpAction {
1815 key: 's',
1816 key_str: "s",
1817 label: "Tunnels: Sort",
1818 aliases: &["order tunnels"],
1819 target: JumpActionTarget::Tunnels,
1820 modifiers: KeyModifiers::NONE,
1821 },
1822 JumpAction {
1823 key: 'R',
1824 key_str: "R",
1825 label: "Containers: Refresh all hosts",
1826 aliases: &["reload containers", "fetch", "rescan"],
1827 target: JumpActionTarget::Containers,
1828 modifiers: KeyModifiers::NONE,
1829 },
1830 JumpAction {
1831 key: 's',
1832 key_str: "s",
1833 label: "Containers: Cycle sort",
1834 aliases: &["order containers", "sort by host", "sort by name"],
1835 target: JumpActionTarget::Containers,
1836 modifiers: KeyModifiers::NONE,
1837 },
1838 JumpAction {
1839 key: 'v',
1840 key_str: "v",
1841 label: "Containers: Toggle detail panel",
1842 aliases: &["show details", "hide details", "compact view"],
1843 target: JumpActionTarget::Containers,
1844 modifiers: KeyModifiers::NONE,
1845 },
1846 JumpAction {
1847 key: 'e',
1848 key_str: "e",
1849 label: "Containers: Exec into container",
1850 aliases: &["shell", "bash", "sh", "terminal", "attach"],
1851 target: JumpActionTarget::Containers,
1852 modifiers: KeyModifiers::NONE,
1853 },
1854 JumpAction {
1855 key: 'l',
1856 key_str: "l",
1857 label: "Containers: View logs",
1858 aliases: &["log", "stdout", "stderr", "tail"],
1859 target: JumpActionTarget::Containers,
1860 modifiers: KeyModifiers::NONE,
1861 },
1862 JumpAction {
1863 key: 'K',
1864 key_str: "K",
1865 label: "Containers: Restart container",
1866 aliases: &["bounce", "reload"],
1867 target: JumpActionTarget::Containers,
1868 modifiers: KeyModifiers::NONE,
1869 },
1870 JumpAction {
1871 key: 'S',
1872 key_str: "S",
1873 label: "Containers: Stop container",
1874 aliases: &["halt", "kill", "down"],
1875 target: JumpActionTarget::Containers,
1876 modifiers: KeyModifiers::NONE,
1877 },
1878 JumpAction {
1879 key: 'a',
1880 key_str: "a",
1881 label: "Containers: Add host",
1882 aliases: &["pick host", "scan host", "track host", "new host"],
1883 target: JumpActionTarget::Containers,
1884 modifiers: KeyModifiers::NONE,
1885 },
1886 JumpAction {
1887 key: 'r',
1888 key_str: "r",
1889 label: "Containers: Refresh selected host",
1890 aliases: &["reload host", "rescan host", "refresh host"],
1891 target: JumpActionTarget::Containers,
1892 modifiers: KeyModifiers::NONE,
1893 },
1894 JumpAction {
1895 key: ' ',
1896 key_str: " ",
1897 label: "Containers: Fold or unfold host",
1898 aliases: &["collapse", "expand", "toggle host", "group"],
1899 target: JumpActionTarget::Containers,
1900 modifiers: KeyModifiers::NONE,
1901 },
1902 JumpAction {
1903 key: 'k',
1904 key_str: "k",
1905 label: "Containers: Restart compose stack",
1906 aliases: &["stack", "docker compose", "podman compose", "project"],
1907 target: JumpActionTarget::Containers,
1908 modifiers: KeyModifiers::CONTROL,
1909 },
1910 JumpAction {
1914 key: 'c',
1915 key_str: "c",
1916 label: "Keys: Copy public key",
1917 aliases: &["yank", "clipboard", "pubkey"],
1918 target: JumpActionTarget::Keys,
1919 modifiers: KeyModifiers::NONE,
1920 },
1921 JumpAction {
1922 key: 'p',
1923 key_str: "p",
1924 label: "Keys: Push to host",
1925 aliases: &["install", "ssh-copy-id", "deploy", "upload"],
1926 target: JumpActionTarget::Keys,
1927 modifiers: KeyModifiers::NONE,
1928 },
1929 JumpAction {
1930 key: 'V',
1931 key_str: "V",
1932 label: "Keys: Sign Vault SSH certificate",
1933 aliases: &["vault", "renew cert", "sign"],
1934 target: JumpActionTarget::Keys,
1935 modifiers: KeyModifiers::NONE,
1936 },
1937 JumpAction {
1938 key: '?',
1939 key_str: "?",
1940 label: "Help: Show keybindings",
1941 aliases: &["help", "shortcuts", "manual", "keymap"],
1942 target: JumpActionTarget::Hosts,
1943 modifiers: KeyModifiers::NONE,
1944 },
1945];
1946
1947pub const PALETTE_PER_SECTION_CAP: usize = 32;
1952
1953pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
1956 if let Some((prefix, rest)) = query.split_once(':') {
1957 let scope = match prefix.trim() {
1958 "user" => Some(QueryScope::User),
1959 "host" => Some(QueryScope::Hostname),
1960 "proxy" => Some(QueryScope::ProxyJump),
1961 "vault" => Some(QueryScope::VaultSsh),
1962 "tag" => Some(QueryScope::Tag),
1963 _ => None,
1964 };
1965 if scope.is_some() {
1966 return (scope, rest.trim_start());
1967 }
1968 }
1969 (None, query)
1970}
1971
1972#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1973pub enum QueryScope {
1974 User,
1975 Hostname,
1976 ProxyJump,
1977 VaultSsh,
1978 Tag,
1979}
1980
1981fn preview(s: &str, max: usize) -> String {
1983 let s = s.replace('\n', " ");
1984 let chars: Vec<char> = s.chars().collect();
1985 if chars.len() <= max {
1986 s
1987 } else {
1988 let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
1989 out.push_str("...");
1990 out
1991 }
1992}
1993
1994fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
1999 let scope = scope?;
2000 match (hit, scope) {
2001 (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
2002 (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
2003 Some(vec![&h.hostname])
2004 }
2005 (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
2006 Some(vec![&h.proxy_jump])
2007 }
2008 (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
2009 (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
2010 _ => None,
2012 }
2013}
2014
2015pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
2020 if query.is_empty() {
2021 return None;
2022 }
2023 let q = query.to_lowercase();
2024 let alias_hit = host.alias.to_lowercase().contains(&q);
2025 let hostname_hit = host.hostname.to_lowercase().contains(&q);
2026 if alias_hit || hostname_hit {
2027 return None;
2028 }
2029 if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
2030 return Some(MatchSource::User);
2031 }
2032 if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
2033 return Some(MatchSource::ProxyJump);
2034 }
2035 if let Some(role) = &host.vault_ssh
2036 && role.to_lowercase().contains(&q)
2037 {
2038 return Some(MatchSource::VaultSsh);
2039 }
2040 if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
2041 return Some(MatchSource::IdentityFile);
2042 }
2043 None
2044}
2045
2046#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2047pub enum MatchSource {
2048 User,
2049 ProxyJump,
2050 VaultSsh,
2051 IdentityFile,
2052}
2053
2054fn kind_rank(k: SourceKind) -> u8 {
2055 match k {
2056 SourceKind::Host => 0,
2057 SourceKind::Tunnel => 1,
2058 SourceKind::Container => 2,
2059 SourceKind::Snippet => 3,
2060 SourceKind::Action => 4,
2061 }
2062}
2063
2064fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
2069 if let Some(target) = prior
2070 && let Some(idx) = hits.iter().position(|h| &h.identity() == target)
2071 {
2072 return idx;
2073 }
2074 fallback.min(hits.len().saturating_sub(1))
2075}
2076
2077impl JumpAction {
2078 #[cfg(test)]
2079 pub fn all() -> &'static [JumpAction] {
2080 ALL_JUMP_ACTIONS
2081 }
2082
2083 pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
2087 ALL_JUMP_ACTIONS
2088 }
2089}
2090
2091#[cfg(test)]
2092mod tests;