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 selection;
60mod snippet_state;
61mod status_state;
62mod tag_state;
63mod tunnel_state;
64mod ui_state;
65mod update;
66mod vault;
67
68pub use baselines::{FormBaseline, ProviderFormBaseline, SnippetFormBaseline, TunnelFormBaseline};
69pub use container_state::{ContainerSession, ContainerState, LogsView};
70pub use containers_overview::{
71 BulkConfirmContext, BulkConfirmKind, ContainerActionRequest, ContainerExecRequest,
72 ContainerLogsRequest, ContainersOverviewState, ContainersSortMode, InspectCacheEntry,
73 LIST_CACHE_TTL_SECS, LOGS_TAIL, LogsCacheEntry, REFRESH_MAX_PARALLEL, RefreshBatch,
74 RefreshQueueItem,
75};
76pub use file_browser_state::FileBrowserState;
77pub use form_state::FormState;
78pub(crate) use forms::char_to_byte_pos;
79pub use forms::{
80 FormField, HostForm, ProviderFormField, ProviderFormFields, SnippetForm, SnippetFormField,
81 SnippetHostOutput, SnippetOutputState, SnippetParamFormState, TunnelForm, TunnelFormField,
82};
83pub use host_state::{
84 DeletedHost, GroupBy, HostListItem, HostState, ProxyJumpCandidate, SortMode, ViewMode,
85 health_summary_spans, health_summary_spans_for,
86};
87pub use key_push_state::{KeyPushPrompt, KeyPushState};
88pub use keys_state::KeysState;
89pub use password_prompt::{
90 PASSWORD_MAX_CHARS, PasswordPromptField, PasswordPromptState, PendingRetry, SessionPasswords,
91 source_allows_prompt,
92};
93pub use ping::{
94 PingState, PingStatus, classify_ping, ping_sort_key, propagate_ping_to_dependents, status_glyph,
95};
96pub use provider_state::{
97 LabelMigrationField, PendingLabelMigration, PendingPurge, ProviderRow, ProviderState,
98 SyncRecord,
99};
100pub(crate) use reload_state::config_changed;
101pub use reload_state::{ConflictState, ReloadState};
102pub use screen::{ContainerLogsSearch, Screen, StackMember, TopPage, WhatsNewState};
103pub use search::SearchState;
104pub use snippet_state::{SnippetHostPick, SnippetHostPickPurpose, SnippetState};
105pub use status_state::{MessageClass, StatusCenter, StatusMessage};
106pub use tag_state::{
107 BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, TagState,
108 select_display_tags,
109};
110pub use tunnel_state::{TunnelSortMode, TunnelState};
111pub use ui_state::UiSelection;
112pub use update::UpdateState;
113pub use vault::VaultState;
114
115impl Drop for App {
117 fn drop(&mut self) {
118 for (alias, mut tunnel) in self.tunnels.active.drain() {
119 if let Err(e) = tunnel.child.kill() {
120 log::debug!("[external] Failed to kill tunnel for {alias} on shutdown: {e}");
121 }
122 let _ = tunnel.child.wait();
123 }
124 if let Some(handle) = self.vault.cancel_signing_run() {
128 let _ = handle.join();
129 }
130 self.keys.push.shutdown();
134 }
135}
136
137pub struct App {
139 pub screen: Screen,
142 pub top_page: TopPage,
145 pub running: bool,
147 pub(crate) hosts_state: HostState,
149
150 pub(crate) status_center: StatusCenter,
153 pub(crate) ui: UiSelection,
155 pub(crate) search: SearchState,
157 pub(crate) reload: ReloadState,
159 pub(crate) conflict: ConflictState,
161
162 pub(crate) keys: KeysState,
164
165 pub(crate) tags: TagState,
167
168 pub(crate) forms: FormState,
170
171 pub(crate) history: ConnectionHistory,
173
174 pub(crate) providers: ProviderState,
176
177 pub(crate) ping: PingState,
179
180 pub(crate) vault: VaultState,
182
183 pub(crate) tunnels: TunnelState,
185
186 pub(crate) snippets: SnippetState,
188
189 pub(crate) update: UpdateState,
191
192 pub(crate) bw_session: Option<String>,
194
195 pub(crate) session_passwords: SessionPasswords,
198 pub(crate) password_prompt: Option<PasswordPromptState>,
200
201 pub(crate) file_browser_state: FileBrowserState,
204 pub(crate) file_browser_session: Option<crate::file_browser::FileBrowserSession>,
206
207 pub(crate) container_state: ContainerState,
210 pub(crate) container_session: Option<ContainerSession>,
212 pub(crate) containers_overview: ContainersOverviewState,
214
215 pub demo_mode: bool,
217
218 pub(crate) env: std::sync::Arc<crate::runtime::env::Env>,
222
223 pub(crate) jump: Option<JumpState>,
225}
226
227impl App {
228 pub fn new(config: SshConfigFile) -> Self {
232 #[cfg(test)]
233 let env = std::sync::Arc::new(crate::runtime::env::Env::sandboxed());
234 #[cfg(not(test))]
235 let env = std::sync::Arc::new(crate::runtime::env::Env::from_process());
236 Self::with_env(config, env)
237 }
238
239 pub fn with_env(config: SshConfigFile, env: std::sync::Arc<crate::runtime::env::Env>) -> Self {
243 let hosts = config.host_entries();
244 let patterns = config.pattern_entries();
245 let display_list = Self::build_display_list_from(&config, &hosts, &patterns);
246
247 let initial_selection = display_list.iter().position(|item| {
248 matches!(
249 item,
250 HostListItem::Host { .. } | HostListItem::Pattern { .. }
251 )
252 });
253
254 let reload = ReloadState::from_config(&env, &config);
255 let hosts_state = HostState::from_config(config, hosts, patterns, display_list);
256
257 Self {
258 screen: Screen::HostList,
259 top_page: TopPage::default(),
260 running: true,
261 hosts_state,
262 status_center: StatusCenter::default(),
263 ui: UiSelection::new_with_initial_selection(initial_selection),
264 search: SearchState::default(),
265 reload,
266 conflict: ConflictState::default(),
267 keys: KeysState {
268 list: Vec::new(),
269 list_state: ratatui::widgets::ListState::default(),
270 activity: crate::key_activity::KeyActivityLog::load(env.paths()),
271 push: KeyPushState::default(),
272 },
273 tags: TagState::default(),
274 forms: FormState::default(),
275 history: ConnectionHistory::load(env.paths()),
276 providers: ProviderState::load(env.paths()),
277 ping: PingState::from_preferences(env.paths()),
278 vault: VaultState::default(),
279 tunnels: TunnelState::default(),
280 snippets: SnippetState::with_store_loaded(env.paths()),
281 update: UpdateState::with_current_hint(&env),
282 bw_session: None,
283 session_passwords: SessionPasswords::new(),
284 password_prompt: None,
285 file_browser_state: FileBrowserState::default(),
286 file_browser_session: None,
287 container_state: ContainerState {
288 cache: crate::containers::load_container_cache(env.paths()),
289 ..ContainerState::default()
290 },
291 container_session: None,
292 containers_overview: ContainersOverviewState::default(),
293 demo_mode: false,
294 env,
295 jump: None,
296 }
297 }
298
299 pub(crate) fn env(&self) -> &crate::runtime::env::Env {
301 &self.env
302 }
303
304 pub fn record_key_use(&mut self, alias: &str, now: u64) {
310 let paths = self.env.paths().cloned();
311 crate::key_activity::record_and_flush(&mut self.keys.activity, alias, now, paths.as_ref());
312 }
313
314 pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
318 self.hosts_state
319 .list
320 .iter()
321 .map(|h| h.alias.clone())
322 .collect()
323 }
324
325 pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
331 let new_aliases: Vec<String> = self
332 .hosts_state
333 .list
334 .iter()
335 .filter(|h| !before_aliases.contains(&h.alias))
336 .map(|h| h.alias.clone())
337 .collect();
338 for alias in new_aliases {
339 self.container_state.queue_fetch(alias);
340 }
341 }
342
343 pub fn reload_hosts(&mut self) {
355 let had_pending_vault_write = self.vault.pending_config_write;
356 let mut flushed_vault_write = false;
369 if self.vault.pending_config_write && !self.is_form_open() {
370 if self.external_config_changed() {
371 self.notify_error(
372 crate::messages::vault_config_skipped_external_change().to_string(),
373 );
374 log::warn!(
375 "[config] reload_hosts: skipping deferred vault write. external config changed"
376 );
377 } else {
378 match self.hosts_state.ssh_config.write() {
379 Ok(()) => flushed_vault_write = true,
380 Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
381 }
382 }
383 }
384 self.vault.pending_config_write = false;
387 log::debug!(
388 "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
389 );
390 let had_search = self.search.query.take();
391 let selected_alias = self
392 .selected_host()
393 .map(|h| h.alias.clone())
394 .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
395
396 self.tunnels.summaries_cache.clear();
397 self.hosts_state.render_cache.invalidate();
398 self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
399 self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
400
401 {
406 let valid_aliases: std::collections::HashSet<&str> = self
407 .hosts_state
408 .list
409 .iter()
410 .map(|h| h.alias.as_str())
411 .collect();
412
413 self.vault.prune_orphans(&valid_aliases);
414
415 if self.container_state.prune_orphans(&valid_aliases) {
420 crate::containers::save_container_cache(
421 self.env().paths(),
422 self.container_state.cache(),
423 );
424 }
425
426 let valid_container_ids: std::collections::HashSet<String> = self
430 .container_state
431 .cache()
432 .values()
433 .flat_map(|e| e.containers.iter().map(|c| c.id.clone()))
434 .collect();
435 self.containers_overview
436 .prune_by_container_ids(&valid_container_ids);
437
438 if self.containers_overview.prune_orphans(&valid_aliases)
442 && let Err(e) = crate::preferences::save_containers_collapsed_hosts(
443 self.env().paths(),
444 self.containers_overview.collapsed_hosts(),
445 )
446 {
447 log::warn!("[config] failed to save collapsed_hosts after prune: {e}");
448 }
449
450 self.file_browser_state.prune_orphans(&valid_aliases);
451 self.tunnels.prune_orphans(&valid_aliases);
452 self.ping.prune_orphans(&valid_aliases);
453
454 let before = self.session_passwords.len();
458 self.session_passwords
459 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
460 if self.session_passwords.len() != before {
461 log::debug!(
462 "[purple] reload_hosts: dropped {} session password(s) for hosts that are gone",
463 before - self.session_passwords.len()
464 );
465 }
466 }
467
468 if self.hosts_state.sort_mode == SortMode::Original
469 && matches!(self.hosts_state.group_by, GroupBy::None)
470 {
471 self.hosts_state.display_list = Self::build_display_list_from(
472 &self.hosts_state.ssh_config,
473 &self.hosts_state.list,
474 &self.hosts_state.patterns,
475 );
476 } else {
477 self.apply_sort();
478 }
479
480 if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
482 self.set_screen(Screen::HostList);
483 self.forms.bulk_tag_editor = BulkTagEditorState::default();
484 }
485
486 self.hosts_state.multi_select.clear();
488
489 if let Some(query) = had_search {
491 self.search.query = Some(query);
492 self.apply_filter();
493 } else {
494 self.search.query = None;
495 self.search.filtered_indices.clear();
496 self.search.filtered_pattern_indices.clear();
497 if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
499 self.ui.list_state.select(None);
500 } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
501 matches!(
502 item,
503 HostListItem::Host { .. } | HostListItem::Pattern { .. }
504 )
505 }) {
506 let current = self.ui.list_state.selected().unwrap_or(0);
507 if current >= self.hosts_state.display_list.len()
508 || !matches!(
509 self.hosts_state.display_list.get(current),
510 Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
511 )
512 {
513 self.ui.list_state.select(Some(pos));
514 }
515 } else {
516 self.ui.list_state.select(None);
517 }
518 }
519
520 if let Some(alias) = selected_alias {
522 self.select_host_by_alias(&alias);
523 }
524
525 log::debug!(
526 "[config] reload_hosts: hosts={} patterns={} display_items={}",
527 self.hosts_state.list.len(),
528 self.hosts_state.patterns.len(),
529 self.hosts_state.display_list.len(),
530 );
531 }
532
533 pub fn refresh_cert_cache(&mut self, alias: &str) {
544 if crate::demo_flag::is_demo() {
545 return;
546 }
547 let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
548 self.vault.cert_cache.remove(alias);
549 return;
550 };
551 let role_some = crate::vault_ssh::resolve_vault_role(
552 host.vault_ssh.as_deref(),
553 host.provider.as_deref(),
554 host.provider_label.as_deref(),
555 &self.providers.config,
556 )
557 .is_some();
558 if !role_some {
559 self.vault.cert_cache.remove(alias);
560 return;
561 }
562 let cert_path = match crate::vault_ssh::resolve_cert_path(
563 self.env().paths(),
564 alias,
565 &host.certificate_file,
566 ) {
567 Ok(p) => p,
568 Err(_) => {
569 self.vault.cert_cache.remove(alias);
570 return;
571 }
572 };
573 let status = crate::vault_ssh::check_cert_validity(self.env(), &cert_path);
574 let mtime = std::fs::metadata(&cert_path)
575 .ok()
576 .and_then(|m| m.modified().ok());
577 self.vault.cert_cache.insert(
578 alias.to_string(),
579 (std::time::Instant::now(), status, mtime),
580 );
581 }
582
583 #[cfg(test)]
590 pub fn sorted_provider_names(&self) -> Vec<String> {
591 self.providers.sorted_names()
592 }
593
594 pub fn is_form_open(&self) -> bool {
596 matches!(
597 self.screen,
598 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
599 )
600 }
601
602 pub fn open_jump(&mut self, mode: JumpMode) {
605 log::debug!("[purple] jump: open mode={:?}", mode);
606 let mut state = JumpState::for_mode(mode);
607 let recents_file = jump::load_recents(self.env.paths());
608 state.recents = self.resolve_recents(&recents_file);
609 self.jump = Some(state);
610 self.recompute_jump_hits();
611 }
612
613 pub(crate) fn close_jump(&mut self) {
617 self.jump = None;
618 }
619
620 fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
623 let mode = self
624 .jump
625 .as_ref()
626 .map(|p| p.mode)
627 .unwrap_or(JumpMode::Hosts);
628 let mut out = Vec::with_capacity(file.entries.len());
629 for entry in &file.entries {
630 if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
631 out.push(hit);
632 }
633 }
634 out
635 }
636
637 #[cfg(test)]
641 pub(crate) fn resolve_recent_ref_for_test(
642 &self,
643 r: &RecentRef,
644 mode: JumpMode,
645 ) -> Option<JumpHit> {
646 self.resolve_recent_ref(r, mode)
647 }
648
649 fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
650 match r.kind {
651 SourceKind::Action => {
652 let key_char = r.key.chars().next()?;
653 let actions = JumpAction::for_mode(mode);
654 actions
655 .iter()
656 .find(|a| a.key == key_char)
657 .copied()
658 .map(JumpHit::Action)
659 }
660 SourceKind::Host => {
661 let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
662 Some(JumpHit::Host(HostHit {
663 alias: host.alias.clone(),
664 hostname: host.hostname.clone(),
665 tags: host.tags.clone(),
666 provider: host.provider.clone(),
667 user: host.user.clone(),
668 identity_file: host.identity_file.clone(),
669 proxy_jump: host.proxy_jump.clone(),
670 vault_ssh: host.vault_ssh.clone(),
671 }))
672 }
673 SourceKind::Tunnel => {
674 let (alias, port_str) = r.key.split_once(':')?;
675 let port: u16 = port_str.parse().ok()?;
676 let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
677 let rule = rules.iter().find(|r| r.bind_port == port)?;
678 Some(JumpHit::Tunnel(TunnelHit {
679 alias: alias.to_string(),
680 bind_port: rule.bind_port,
681 bind_port_str: rule.bind_port.to_string(),
682 destination: rule.display(),
683 active: self.tunnels.active.contains_key(alias),
684 }))
685 }
686 SourceKind::Container => {
687 let (alias, name) = r.key.split_once('/')?;
688 let entry = self.container_state.cache.get(alias)?;
689 let info = entry.containers.iter().find(|c| c.names == name)?;
690 Some(JumpHit::Container(ContainerHit {
691 alias: alias.to_string(),
692 container_name: info.names.clone(),
693 container_id: info.id.clone(),
694 state: info.state.clone(),
695 }))
696 }
697 SourceKind::Snippet => {
698 let snippet = self.snippets.store.get(&r.key)?;
699 Some(JumpHit::Snippet(SnippetHit {
700 name: snippet.name.clone(),
701 command_preview: preview(&snippet.command, 40),
702 }))
703 }
704 }
705 }
706
707 pub fn recompute_jump_hits(&mut self) {
713 let Some(mut state) = self.jump.take() else {
714 return;
715 };
716 let prior_identity = state
720 .visible_hits()
721 .get(state.selected)
722 .map(|h| h.identity());
723
724 let candidates = self.collect_jump_candidates(state.mode);
725 if state.query.is_empty() {
726 state.hits = candidates;
727 state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
728 self.jump = Some(state);
729 return;
730 }
731
732 let (scope, effective_query) = parse_query_scope(&state.query);
737
738 use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
739 use nucleo_matcher::{Config, Matcher, Utf32Str};
740 let matcher_state = state
741 .matcher
742 .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
743 let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
744 let mut buf: Vec<char> = Vec::new();
745 let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
746 for hit in candidates {
747 let mut best: u32 = 0;
748 let scoped_haystacks = scoped_haystacks_for(&hit, scope);
752 let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
753 hs
754 } else {
755 hit.haystacks()
756 };
757 for haystack in haystacks {
758 buf.clear();
759 let chars = Utf32Str::new(haystack, &mut buf);
760 if let Some(score) = pattern.score(chars, matcher_state) {
761 best = best.max(score);
762 }
763 }
764 if let JumpHit::Action(a) = &hit {
774 let mode_match = matches!(
775 (state.mode, a.target),
776 (JumpMode::Hosts, JumpActionTarget::Hosts)
777 | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
778 | (JumpMode::Containers, JumpActionTarget::Containers)
779 | (JumpMode::Keys, JumpActionTarget::Keys)
780 );
781 let single = effective_query.chars().next();
782 let exact_hotkey = effective_query.chars().count() == 1
783 && single
784 .map(|c| c.eq_ignore_ascii_case(&a.key))
785 .unwrap_or(false);
786 let bump = if exact_hotkey && mode_match {
787 20_000
788 } else if exact_hotkey {
789 10_000
790 } else if mode_match && best > 0 {
791 1_000
796 } else {
797 0
798 };
799 if bump > 0 {
800 best = best.saturating_add(bump);
801 }
802 }
803 let floor = match &hit {
807 JumpHit::Action(_) => jump::PALETTE_ACTION_FLOOR,
808 _ => 1,
809 };
810 if best >= floor {
811 scored.push((hit, best));
812 }
813 }
814 scored.sort_by(|a, b| {
817 b.1.cmp(&a.1)
818 .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
819 });
820 let mut per_kind: [usize; 5] = [0; 5];
823 let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
824 for (hit, _) in scored {
825 let slot = kind_rank(hit.kind()) as usize;
826 if per_kind[slot] < PALETTE_PER_SECTION_CAP {
827 per_kind[slot] += 1;
828 filtered.push(hit);
829 }
830 }
831 state.hits = filtered;
832 let display = state.visible_hits();
841 let top_display = state
842 .hits
843 .first()
844 .map(|h| h.kind())
845 .and_then(|k| display.iter().position(|h| h.kind() == k))
846 .unwrap_or(0);
847 state.selected = restore_selection(&display, prior_identity.as_ref(), top_display);
848 log::debug!(
849 "[purple] jump: recompute selected={} of {} hits (top_display={})",
850 state.selected,
851 state.hits.len(),
852 top_display
853 );
854 self.jump = Some(state);
855 }
856
857 fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
858 let mut out: Vec<JumpHit> = Vec::new();
859 for h in &self.hosts_state.list {
861 out.push(JumpHit::Host(HostHit {
862 alias: h.alias.clone(),
863 hostname: h.hostname.clone(),
864 tags: h.tags.clone(),
865 provider: h.provider.clone(),
866 user: h.user.clone(),
867 identity_file: h.identity_file.clone(),
868 proxy_jump: h.proxy_jump.clone(),
869 vault_ssh: h.vault_ssh.clone(),
870 }));
871 }
872 for h in &self.hosts_state.list {
874 let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
875 for rule in rules {
876 out.push(JumpHit::Tunnel(TunnelHit {
877 alias: h.alias.clone(),
878 bind_port: rule.bind_port,
879 bind_port_str: rule.bind_port.to_string(),
880 destination: rule.display(),
881 active: self.tunnels.active.contains_key(&h.alias),
882 }));
883 }
884 }
885 for (alias, entry) in &self.container_state.cache {
888 for info in &entry.containers {
889 out.push(JumpHit::Container(ContainerHit {
890 alias: alias.clone(),
891 container_name: info.names.clone(),
892 container_id: info.id.clone(),
893 state: info.state.clone(),
894 }));
895 }
896 }
897 for snippet in &self.snippets.store.snippets {
899 out.push(JumpHit::Snippet(SnippetHit {
900 name: snippet.name.clone(),
901 command_preview: preview(&snippet.command, 40),
902 }));
903 }
904 for a in JumpAction::for_mode(mode) {
906 out.push(JumpHit::Action(*a));
907 }
908 out
909 }
910
911 pub fn record_jump_hit(&mut self, hit: &JumpHit) {
917 if self.demo_mode {
918 log::debug!("[purple] jump: record skipped (demo mode)");
919 return;
920 }
921 let paths = self.env.paths().cloned();
922 let mut file = jump::load_recents(paths.as_ref());
923 jump::touch_recent(&mut file, hit.identity());
924 if let Err(e) = jump::save_recents(&file, paths.as_ref()) {
925 log::warn!("[purple] failed to save recents: {e}");
926 }
927 }
928
929 pub(crate) fn open_file_browser(&mut self, session: crate::file_browser::FileBrowserSession) {
933 let alias = session.alias.clone();
934 self.file_browser_session = Some(session);
935 self.set_screen(Screen::FileBrowser { alias });
936 }
937
938 pub(crate) fn close_file_browser(&mut self) {
942 if let Some(fb) = self.file_browser_session.take() {
943 self.file_browser_state
944 .host_paths
945 .insert(fb.alias, (fb.local_path, fb.remote_path));
946 }
947 self.set_screen(Screen::HostList);
948 }
949
950 pub fn flush_pending_vault_write(&mut self) -> bool {
953 if !self.vault.pending_config_write || self.is_form_open() {
954 return false;
955 }
956 self.reload_hosts();
958 true
959 }
960
961 pub fn post_init(&mut self) {
965 let outcome = crate::onboarding::evaluate(self.env().paths());
966 if let Some(text) = outcome.upgrade_toast {
967 self.enqueue_sticky_toast(text);
968 }
969 self.scan_keys();
973 }
974
975 fn enqueue_sticky_toast(&mut self, text: String) {
976 log::debug!("[purple] enqueue sticky toast: {}", text);
977 let msg = StatusMessage {
978 text,
979 class: MessageClass::Success,
980 tick_count: 0,
981 sticky: true,
982 created_at: std::time::Instant::now(),
983 };
984 self.status_center.toast = Some(msg);
985 }
986
987 pub fn notify(&mut self, text: impl Into<String>) {
989 self.status_center.set_status(text, false);
990 }
991
992 pub fn notify_error(&mut self, text: impl Into<String>) {
994 self.status_center.set_status(text, true);
995 }
996
997 pub fn notify_background(&mut self, text: impl Into<String>) {
999 self.status_center.set_background_status(text, false);
1000 }
1001
1002 pub fn notify_background_error(&mut self, text: impl Into<String>) {
1004 self.status_center.set_background_status(text, true);
1005 }
1006
1007 pub fn notify_warning(&mut self, text: impl Into<String>) {
1019 let msg = StatusMessage {
1020 text: text.into(),
1021 class: MessageClass::Warning,
1022 tick_count: 0,
1023 sticky: false,
1024 created_at: std::time::Instant::now(),
1025 };
1026 log::debug!("[purple] toast <- Warning: {}", msg.text);
1027 self.status_center.push_toast(msg);
1028 }
1029
1030 pub fn notify_progress(&mut self, text: impl Into<String>) {
1032 self.status_center.set_sticky_status(text, false);
1033 }
1034
1035 pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
1037 self.status_center.set_sticky_status(text, true);
1038 }
1039
1040 pub fn notify_info(&mut self, text: impl Into<String>) {
1042 self.status_center.set_info_status(text);
1043 }
1044
1045 pub(crate) fn clear_status(&mut self) {
1050 self.status_center.clear_status();
1051 }
1052
1053 pub fn tick_status(&mut self) {
1060 if !self.providers.syncing.is_empty() {
1062 return;
1063 }
1064 if let Some(ref status) = self.status_center.status {
1065 if status.sticky {
1066 return;
1067 }
1068 let timeout_ms = status.timeout_ms();
1069 if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
1070 {
1071 log::debug!("[purple] footer status expired: {}", status.text);
1072 self.status_center.status = None;
1073 }
1074 }
1075 }
1076
1077 pub fn tick_toast(&mut self) {
1079 self.status_center.tick_toast();
1080 }
1081
1082 pub fn check_config_changed(&mut self) {
1086 if matches!(
1087 self.screen,
1088 Screen::AddHost
1089 | Screen::EditHost { .. }
1090 | Screen::ProviderForm { .. }
1091 | Screen::TunnelList { .. }
1092 | Screen::TunnelForm { .. }
1093 | Screen::HostDetail { .. }
1094 | Screen::SnippetPicker
1095 | Screen::SnippetForm
1096 | Screen::SnippetOutput
1097 | Screen::SnippetParamForm
1098 | Screen::FileBrowser { .. }
1099 | Screen::Containers { .. }
1100 | Screen::ConfirmDelete { .. }
1101 | Screen::ConfirmHostKeyReset { .. }
1102 | Screen::ConfirmHostKeyTrust { .. }
1103 | Screen::PasswordPrompt
1104 | Screen::ConfirmPurgeStale
1105 | Screen::ConfirmImport { .. }
1106 | Screen::ConfirmVaultSign
1107 | Screen::TagPicker
1108 | Screen::BulkTagEditor
1109 | Screen::ThemePicker
1110 | Screen::WhatsNew(_)
1111 ) || self.tags.input.is_some()
1112 {
1113 return;
1114 }
1115 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1116 let changed = current_mtime != self.reload.last_modified
1117 || self
1118 .reload
1119 .include_mtimes
1120 .iter()
1121 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1122 || self
1123 .reload
1124 .include_dir_mtimes
1125 .iter()
1126 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
1127 if changed {
1128 log::debug!(
1129 "[config] check_config_changed: mtime drift detected on {} -> reloading",
1130 self.reload.config_path.display()
1131 );
1132 if let Ok(new_config) =
1133 SshConfigFile::parse_with_env(&self.reload.config_path, &self.env)
1134 {
1135 let before_aliases = self.snapshot_alias_set();
1136 self.hosts_state.ssh_config = new_config;
1137 self.hosts_state.undo_stack.clear();
1139 log::debug!(
1141 "[config] external config change: clearing {} ping result(s) + timestamps",
1142 self.ping.status.len()
1143 );
1144 self.ping.status.clear();
1145 self.ping.last_checked.clear();
1146 self.ping.filter_down_only = false;
1147 self.ping.checked_at = None;
1148 self.reload_hosts();
1149 self.reload.last_modified = current_mtime;
1150 self.reload.include_mtimes =
1151 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1152 self.reload.include_dir_mtimes = reload_state::snapshot_include_dir_mtimes(
1153 &self.env,
1154 &self.hosts_state.ssh_config,
1155 );
1156 let count = self.hosts_state.list.len();
1157 self.notify_background(crate::messages::config_reloaded(count));
1158 self.queue_new_aliases_since(&before_aliases);
1159 }
1160 }
1161 }
1162
1163 pub fn check_keys_changed(&mut self) {
1173 if self.demo_mode {
1174 return;
1175 }
1176 if matches!(
1177 self.screen,
1178 Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
1179 ) {
1180 return;
1181 }
1182 let Some(ssh_dir) = self.env().paths().map(crate::runtime::env::Paths::ssh_dir) else {
1183 return;
1184 };
1185 let current_dir_mtime = reload_state::get_mtime(&ssh_dir);
1186 let dir_changed = current_dir_mtime != self.reload.keys_dir_mtime;
1187 let files_changed = self
1188 .reload
1189 .key_file_mtimes
1190 .iter()
1191 .any(|(path, old)| reload_state::get_mtime(path) != *old);
1192 if !dir_changed && !files_changed {
1193 return;
1194 }
1195 log::debug!(
1196 "[purple] check_keys_changed: drift detected on {} (dir={} files={}) -> rescan",
1197 ssh_dir.display(),
1198 dir_changed,
1199 files_changed,
1200 );
1201 let previous = self.keys.list.len();
1202 self.scan_keys();
1203 let after = self.keys.list.len();
1204 if let Some(sel) = self.keys.list_state.selected() {
1207 if sel >= after {
1208 let next = after.checked_sub(1);
1209 self.keys.list_state.select(next);
1210 }
1211 } else if after > 0 {
1212 self.keys.list_state.select(Some(0));
1213 }
1214 if previous != after {
1215 log::debug!(
1216 "[purple] check_keys_changed: rescan {} -> {} keys",
1217 previous,
1218 after
1219 );
1220 }
1221 }
1222
1223 pub fn external_config_changed(&self) -> bool {
1232 let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1233 current_mtime != self.reload.last_modified
1234 || self
1235 .reload
1236 .include_mtimes
1237 .iter()
1238 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1239 || self
1240 .reload
1241 .include_dir_mtimes
1242 .iter()
1243 .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1244 }
1245
1246 pub fn update_last_modified(&mut self) {
1248 self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
1249 self.reload.include_mtimes =
1250 reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1251 self.reload.include_dir_mtimes =
1252 reload_state::snapshot_include_dir_mtimes(&self.env, &self.hosts_state.ssh_config);
1253 }
1254
1255 pub fn has_any_vault_role(&self) -> bool {
1257 for host in &self.hosts_state.list {
1258 if host.vault_ssh.is_some() {
1259 return true;
1260 }
1261 }
1262 for section in &self.providers.config.sections {
1263 if !section.vault_role.is_empty() {
1264 return true;
1265 }
1266 }
1267 false
1268 }
1269
1270 pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
1272 self.tunnels.poll()
1273 }
1274
1275 pub fn refresh_tunnel_bind_ports(&mut self) {
1280 let mut ports: Vec<(String, u16, u32)> = Vec::new();
1281 for (alias, tunnel) in &self.tunnels.active {
1282 let pid = tunnel.child.id();
1283 for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
1284 ports.push((alias.clone(), rule.bind_port, pid));
1285 }
1286 }
1287 self.tunnels.set_lsof_ports(ports);
1288 }
1289}
1290
1291pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
1293 if len == 0 {
1294 return;
1295 }
1296 let i = match state.selected() {
1297 Some(i) => {
1298 if forward {
1299 if i >= len - 1 { 0 } else { i + 1 }
1300 } else if i == 0 {
1301 len - 1
1302 } else {
1303 i - 1
1304 }
1305 }
1306 None => 0,
1307 };
1308 state.select(Some(i));
1309}
1310
1311pub(crate) fn apply_bulk_tags(
1316 hosts: &mut HostState,
1317 forms: &mut FormState,
1318) -> Result<BulkTagApplyResult, String> {
1319 if forms.bulk_tag_editor.aliases.is_empty() {
1320 return Err(crate::messages::BULK_TAG_NO_HOSTS_SELECTED.to_string());
1321 }
1322 let aliases = forms.bulk_tag_editor.aliases.clone();
1323 let rows = forms.bulk_tag_editor.rows.clone();
1324 let skipped_set: std::collections::HashSet<&str> = forms
1325 .bulk_tag_editor
1326 .skipped_included
1327 .iter()
1328 .map(|s| s.as_str())
1329 .collect();
1330
1331 let has_pending = rows.iter().any(|r| r.action != BulkTagAction::Leave);
1334 if !has_pending {
1335 return Ok(BulkTagApplyResult {
1336 skipped_included: skipped_set.len(),
1337 ..Default::default()
1338 });
1339 }
1340
1341 let mut changed_hosts: std::collections::HashSet<String> = std::collections::HashSet::new();
1342 let mut added = 0usize;
1343 let mut removed = 0usize;
1344 let mut skipped_included = 0usize;
1345 let mut undo_snapshot: Vec<(String, Vec<String>)> = Vec::new();
1349
1350 for alias in &aliases {
1351 if skipped_set.contains(alias.as_str()) {
1352 skipped_included += 1;
1353 continue;
1354 }
1355 let Some(host) = hosts.list.iter().find(|h| &h.alias == alias) else {
1356 continue;
1357 };
1358 let original_tags = host.tags.clone();
1359 let mut new_tags = original_tags.clone();
1360 let mut host_changed = false;
1361 for row in &rows {
1362 match row.action {
1363 BulkTagAction::Leave => {}
1364 BulkTagAction::AddToAll => {
1365 if !new_tags.iter().any(|t| t == &row.tag) {
1366 new_tags.push(row.tag.clone());
1367 added += 1;
1368 host_changed = true;
1369 }
1370 }
1371 BulkTagAction::RemoveFromAll => {
1372 let before = new_tags.len();
1373 new_tags.retain(|t| t != &row.tag);
1374 if new_tags.len() != before {
1375 removed += 1;
1376 host_changed = true;
1377 }
1378 }
1379 }
1380 }
1381 if host_changed {
1382 let _ = hosts.ssh_config.set_host_tags(alias, &new_tags);
1383 changed_hosts.insert(alias.clone());
1384 undo_snapshot.push((alias.clone(), original_tags));
1385 }
1386 }
1387
1388 if changed_hosts.is_empty() {
1389 return Ok(BulkTagApplyResult {
1390 skipped_included,
1391 ..Default::default()
1392 });
1393 }
1394
1395 let config_backup = hosts.ssh_config.clone();
1398 if let Err(e) = hosts.ssh_config.write() {
1399 log::error!("[purple] bulk tag apply write failed: {e}");
1400 hosts.ssh_config = config_backup;
1401 return Err(format!("Failed to save: {}", e));
1402 }
1403
1404 log::debug!(
1405 "[purple] bulk tag apply: {} hosts, +{} -{}, skipped {}",
1406 changed_hosts.len(),
1407 added,
1408 removed,
1409 skipped_included
1410 );
1411 if !undo_snapshot.is_empty() {
1414 forms.bulk_tag_undo = Some(undo_snapshot);
1415 }
1416
1417 Ok(BulkTagApplyResult {
1418 changed_hosts: changed_hosts.len(),
1419 added,
1420 removed,
1421 skipped_included,
1422 })
1423}
1424
1425pub(crate) fn bulk_tag_cycle_current(ui: &UiSelection, forms: &mut FormState) {
1428 let Some(idx) = ui.bulk_tag_editor_state.selected() else {
1429 return;
1430 };
1431 if let Some(row) = forms.bulk_tag_editor.rows.get_mut(idx) {
1432 row.action = row.action.cycle();
1433 }
1434}
1435
1436pub(crate) fn bulk_tag_commit_new_tag(ui: &mut UiSelection, forms: &mut FormState) {
1440 let Some(input) = forms.bulk_tag_editor.new_tag_input.take() else {
1441 return;
1442 };
1443 forms.bulk_tag_editor.new_tag_cursor = 0;
1444 let tag = input.trim().to_string();
1445 if tag.is_empty() {
1446 return;
1447 }
1448 if let Some(existing) = forms.bulk_tag_editor.rows.iter().position(|r| r.tag == tag) {
1449 forms.bulk_tag_editor.rows[existing].action = BulkTagAction::AddToAll;
1450 ui.bulk_tag_editor_state.select(Some(existing));
1451 return;
1452 }
1453 let row = BulkTagRow {
1454 tag,
1455 initial_count: 0,
1456 action: BulkTagAction::AddToAll,
1457 };
1458 let insert_at = forms.bulk_tag_editor.rows.len();
1459 forms.bulk_tag_editor.rows.push(row);
1460 ui.bulk_tag_editor_state.select(Some(insert_at));
1461}
1462
1463pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
1465 if len == 0 {
1466 return;
1467 }
1468 let current = state.selected().unwrap_or(0);
1469 let next = (current + page_size).min(len - 1);
1470 state.select(Some(next));
1471}
1472
1473pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
1475 if len == 0 {
1476 return;
1477 }
1478 let current = state.selected().unwrap_or(0);
1479 let prev = current.saturating_sub(page_size);
1480 state.select(Some(prev));
1481}
1482
1483pub use jump::{
1487 ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, JumpMode, JumpState, RecentRef,
1488 RecentsFile, SnippetHit, SourceKind, TunnelHit,
1489};
1490
1491#[cfg(test)]
1495pub type PaletteCommand = JumpAction;
1496
1497static ALL_JUMP_ACTIONS: &[JumpAction] = &[
1504 JumpAction {
1505 key: 'a',
1506 key_str: "a",
1507 label: "Hosts: Add host",
1508 aliases: &["new", "create"],
1509 target: JumpActionTarget::Hosts,
1510 modifiers: KeyModifiers::NONE,
1511 },
1512 JumpAction {
1513 key: 'A',
1514 key_str: "A",
1515 label: "Hosts: Add pattern",
1516 aliases: &["new pattern", "wildcard"],
1517 target: JumpActionTarget::Hosts,
1518 modifiers: KeyModifiers::NONE,
1519 },
1520 JumpAction {
1521 key: 'e',
1522 key_str: "e",
1523 label: "Hosts: Edit host",
1524 aliases: &["modify", "change"],
1525 target: JumpActionTarget::Hosts,
1526 modifiers: KeyModifiers::NONE,
1527 },
1528 JumpAction {
1529 key: 'd',
1530 key_str: "d",
1531 label: "Hosts: Delete host",
1532 aliases: &["remove", "rm"],
1533 target: JumpActionTarget::Hosts,
1534 modifiers: KeyModifiers::NONE,
1535 },
1536 JumpAction {
1537 key: 'c',
1538 key_str: "c",
1539 label: "Hosts: Clone host",
1540 aliases: &["duplicate", "copy"],
1541 target: JumpActionTarget::Hosts,
1542 modifiers: KeyModifiers::NONE,
1543 },
1544 JumpAction {
1545 key: 'u',
1546 key_str: "u",
1547 label: "Hosts: Undo delete",
1548 aliases: &["restore"],
1549 target: JumpActionTarget::Hosts,
1550 modifiers: KeyModifiers::NONE,
1551 },
1552 JumpAction {
1553 key: 't',
1554 key_str: "t",
1555 label: "Hosts: Tag host",
1556 aliases: &["label", "category"],
1557 target: JumpActionTarget::Hosts,
1558 modifiers: KeyModifiers::NONE,
1559 },
1560 JumpAction {
1561 key: 'i',
1562 key_str: "i",
1563 label: "Hosts: Show all directives",
1564 aliases: &["raw", "config", "settings"],
1565 target: JumpActionTarget::Hosts,
1566 modifiers: KeyModifiers::NONE,
1567 },
1568 JumpAction {
1569 key: 'y',
1570 key_str: "y",
1571 label: "Clipboard: Copy SSH command",
1572 aliases: &["yank"],
1573 target: JumpActionTarget::Hosts,
1574 modifiers: KeyModifiers::NONE,
1575 },
1576 JumpAction {
1577 key: 'x',
1578 key_str: "x",
1579 label: "Clipboard: Copy config block",
1580 aliases: &["yank config"],
1581 target: JumpActionTarget::Hosts,
1582 modifiers: KeyModifiers::NONE,
1583 },
1584 JumpAction {
1585 key: 'X',
1586 key_str: "X",
1587 label: "Hosts: Purge stale hosts",
1588 aliases: &["clean", "cleanup"],
1589 target: JumpActionTarget::Hosts,
1590 modifiers: KeyModifiers::NONE,
1591 },
1592 JumpAction {
1593 key: 'F',
1594 key_str: "F",
1595 label: "Files: Browse remote files",
1596 aliases: &[
1597 "browse",
1598 "filesystem",
1599 "scp",
1600 "sftp",
1601 "transfer",
1602 "explorer",
1603 "open",
1604 ],
1605 target: JumpActionTarget::Hosts,
1606 modifiers: KeyModifiers::NONE,
1607 },
1608 JumpAction {
1609 key: 'C',
1610 key_str: "C",
1611 label: "Containers: List containers",
1612 aliases: &["docker", "podman", "ps", "open"],
1613 target: JumpActionTarget::Hosts,
1614 modifiers: KeyModifiers::NONE,
1615 },
1616 JumpAction {
1617 key: 'K',
1618 key_str: "K",
1619 label: "Keys: Manage SSH keys",
1620 aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
1621 target: JumpActionTarget::Hosts,
1622 modifiers: KeyModifiers::NONE,
1623 },
1624 JumpAction {
1625 key: 'S',
1626 key_str: "S",
1627 label: "Providers: Manage cloud sync",
1628 aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
1629 target: JumpActionTarget::Hosts,
1630 modifiers: KeyModifiers::NONE,
1631 },
1632 JumpAction {
1633 key: 'V',
1634 key_str: "V",
1635 label: "Vault: Sign certificate",
1636 aliases: &["hashicorp", "ssh cert", "vault ssh"],
1637 target: JumpActionTarget::Hosts,
1638 modifiers: KeyModifiers::NONE,
1639 },
1640 JumpAction {
1641 key: 'I',
1642 key_str: "I",
1643 label: "Hosts: Import from known_hosts",
1644 aliases: &["known", "import"],
1645 target: JumpActionTarget::Hosts,
1646 modifiers: KeyModifiers::NONE,
1647 },
1648 JumpAction {
1649 key: 'm',
1650 key_str: "m",
1651 label: "Settings: Switch theme",
1652 aliases: &["color", "appearance", "dark", "light"],
1653 target: JumpActionTarget::Hosts,
1654 modifiers: KeyModifiers::NONE,
1655 },
1656 JumpAction {
1657 key: 'n',
1658 key_str: "n",
1659 label: "Help: What's new",
1660 aliases: &["changelog", "news", "release notes"],
1661 target: JumpActionTarget::Hosts,
1662 modifiers: KeyModifiers::NONE,
1663 },
1664 JumpAction {
1665 key: 'r',
1666 key_str: "r",
1667 label: "Snippets: Run snippet",
1668 aliases: &["execute", "command"],
1669 target: JumpActionTarget::Hosts,
1670 modifiers: KeyModifiers::NONE,
1671 },
1672 JumpAction {
1673 key: 'R',
1674 key_str: "R",
1675 label: "Snippets: Run on all visible",
1676 aliases: &["batch", "execute all"],
1677 target: JumpActionTarget::Hosts,
1678 modifiers: KeyModifiers::NONE,
1679 },
1680 JumpAction {
1681 key: 'p',
1682 key_str: "p",
1683 label: "Hosts: Ping host",
1684 aliases: &["health", "check"],
1685 target: JumpActionTarget::Hosts,
1686 modifiers: KeyModifiers::NONE,
1687 },
1688 JumpAction {
1689 key: 'P',
1690 key_str: "P",
1691 label: "Hosts: Ping all hosts",
1692 aliases: &["health all"],
1693 target: JumpActionTarget::Hosts,
1694 modifiers: KeyModifiers::NONE,
1695 },
1696 JumpAction {
1697 key: '!',
1698 key_str: "!",
1699 label: "Hosts: Show down only",
1700 aliases: &["filter offline", "down only"],
1701 target: JumpActionTarget::Hosts,
1702 modifiers: KeyModifiers::NONE,
1703 },
1704 JumpAction {
1705 key: 's',
1706 key_str: "s",
1707 label: "Hosts: Sort",
1708 aliases: &["order", "arrange", "sort by"],
1709 target: JumpActionTarget::Hosts,
1710 modifiers: KeyModifiers::NONE,
1711 },
1712 JumpAction {
1713 key: 'g',
1714 key_str: "g",
1715 label: "Hosts: Cycle grouping",
1716 aliases: &["group", "group by", "category"],
1717 target: JumpActionTarget::Hosts,
1718 modifiers: KeyModifiers::NONE,
1719 },
1720 JumpAction {
1721 key: 'v',
1722 key_str: "v",
1723 label: "Hosts: Toggle compact view",
1724 aliases: &["view mode", "detail panel", "compact", "detailed"],
1725 target: JumpActionTarget::Hosts,
1726 modifiers: KeyModifiers::NONE,
1727 },
1728 JumpAction {
1729 key: '#',
1730 key_str: "#",
1731 label: "Hosts: Filter by tag",
1732 aliases: &["tag", "tag filter", "filter tag"],
1733 target: JumpActionTarget::Hosts,
1734 modifiers: KeyModifiers::NONE,
1735 },
1736 JumpAction {
1737 key: ' ',
1738 key_str: " ",
1739 label: "Hosts: Mark host",
1740 aliases: &["select", "multi-select", "toggle mark", "checkbox"],
1741 target: JumpActionTarget::Hosts,
1742 modifiers: KeyModifiers::NONE,
1743 },
1744 JumpAction {
1745 key: 'a',
1746 key_str: "a",
1747 label: "Hosts: Select all visible",
1748 aliases: &["select all", "mark all", "bulk select"],
1749 target: JumpActionTarget::Hosts,
1750 modifiers: KeyModifiers::CONTROL,
1751 },
1752 JumpAction {
1756 key: 'T',
1757 key_str: "T",
1758 label: "Tunnels: Manage tunnels",
1759 aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
1760 target: JumpActionTarget::Hosts,
1761 modifiers: KeyModifiers::NONE,
1762 },
1763 JumpAction {
1764 key: 'a',
1765 key_str: "a",
1766 label: "Tunnels: Add tunnel",
1767 aliases: &["new tunnel", "create tunnel", "forward"],
1768 target: JumpActionTarget::Tunnels,
1769 modifiers: KeyModifiers::NONE,
1770 },
1771 JumpAction {
1772 key: 'e',
1773 key_str: "e",
1774 label: "Tunnels: Edit tunnel",
1775 aliases: &["modify tunnel"],
1776 target: JumpActionTarget::Tunnels,
1777 modifiers: KeyModifiers::NONE,
1778 },
1779 JumpAction {
1780 key: 'd',
1781 key_str: "d",
1782 label: "Tunnels: Delete tunnel",
1783 aliases: &["remove tunnel"],
1784 target: JumpActionTarget::Tunnels,
1785 modifiers: KeyModifiers::NONE,
1786 },
1787 JumpAction {
1788 key: 's',
1789 key_str: "s",
1790 label: "Tunnels: Sort",
1791 aliases: &["order tunnels"],
1792 target: JumpActionTarget::Tunnels,
1793 modifiers: KeyModifiers::NONE,
1794 },
1795 JumpAction {
1796 key: 'R',
1797 key_str: "R",
1798 label: "Containers: Refresh all hosts",
1799 aliases: &["reload containers", "fetch", "rescan"],
1800 target: JumpActionTarget::Containers,
1801 modifiers: KeyModifiers::NONE,
1802 },
1803 JumpAction {
1804 key: 's',
1805 key_str: "s",
1806 label: "Containers: Cycle sort",
1807 aliases: &["order containers", "sort by host", "sort by name"],
1808 target: JumpActionTarget::Containers,
1809 modifiers: KeyModifiers::NONE,
1810 },
1811 JumpAction {
1812 key: 'v',
1813 key_str: "v",
1814 label: "Containers: Toggle detail panel",
1815 aliases: &["show details", "hide details", "compact view"],
1816 target: JumpActionTarget::Containers,
1817 modifiers: KeyModifiers::NONE,
1818 },
1819 JumpAction {
1820 key: 'e',
1821 key_str: "e",
1822 label: "Containers: Exec into container",
1823 aliases: &["shell", "bash", "sh", "terminal", "attach"],
1824 target: JumpActionTarget::Containers,
1825 modifiers: KeyModifiers::NONE,
1826 },
1827 JumpAction {
1828 key: 'l',
1829 key_str: "l",
1830 label: "Containers: View logs",
1831 aliases: &["log", "stdout", "stderr", "tail"],
1832 target: JumpActionTarget::Containers,
1833 modifiers: KeyModifiers::NONE,
1834 },
1835 JumpAction {
1836 key: 'K',
1837 key_str: "K",
1838 label: "Containers: Restart container",
1839 aliases: &["bounce", "reload"],
1840 target: JumpActionTarget::Containers,
1841 modifiers: KeyModifiers::NONE,
1842 },
1843 JumpAction {
1844 key: 'S',
1845 key_str: "S",
1846 label: "Containers: Stop container",
1847 aliases: &["halt", "kill", "down"],
1848 target: JumpActionTarget::Containers,
1849 modifiers: KeyModifiers::NONE,
1850 },
1851 JumpAction {
1852 key: 'a',
1853 key_str: "a",
1854 label: "Containers: Add host",
1855 aliases: &["pick host", "scan host", "track host", "new host"],
1856 target: JumpActionTarget::Containers,
1857 modifiers: KeyModifiers::NONE,
1858 },
1859 JumpAction {
1860 key: 'r',
1861 key_str: "r",
1862 label: "Containers: Refresh selected host",
1863 aliases: &["reload host", "rescan host", "refresh host"],
1864 target: JumpActionTarget::Containers,
1865 modifiers: KeyModifiers::NONE,
1866 },
1867 JumpAction {
1868 key: ' ',
1869 key_str: " ",
1870 label: "Containers: Fold or unfold host",
1871 aliases: &["collapse", "expand", "toggle host", "group"],
1872 target: JumpActionTarget::Containers,
1873 modifiers: KeyModifiers::NONE,
1874 },
1875 JumpAction {
1876 key: 'k',
1877 key_str: "k",
1878 label: "Containers: Restart compose stack",
1879 aliases: &["stack", "docker compose", "podman compose", "project"],
1880 target: JumpActionTarget::Containers,
1881 modifiers: KeyModifiers::CONTROL,
1882 },
1883 JumpAction {
1887 key: 'c',
1888 key_str: "c",
1889 label: "Keys: Copy public key",
1890 aliases: &["yank", "clipboard", "pubkey"],
1891 target: JumpActionTarget::Keys,
1892 modifiers: KeyModifiers::NONE,
1893 },
1894 JumpAction {
1895 key: 'p',
1896 key_str: "p",
1897 label: "Keys: Push to host",
1898 aliases: &["install", "ssh-copy-id", "deploy", "upload"],
1899 target: JumpActionTarget::Keys,
1900 modifiers: KeyModifiers::NONE,
1901 },
1902 JumpAction {
1903 key: 'V',
1904 key_str: "V",
1905 label: "Keys: Sign Vault SSH certificate",
1906 aliases: &["vault", "renew cert", "sign"],
1907 target: JumpActionTarget::Keys,
1908 modifiers: KeyModifiers::NONE,
1909 },
1910 JumpAction {
1911 key: '?',
1912 key_str: "?",
1913 label: "Help: Show keybindings",
1914 aliases: &["help", "shortcuts", "manual", "keymap"],
1915 target: JumpActionTarget::Hosts,
1916 modifiers: KeyModifiers::NONE,
1917 },
1918];
1919
1920pub const PALETTE_PER_SECTION_CAP: usize = 32;
1925
1926pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
1929 if let Some((prefix, rest)) = query.split_once(':') {
1930 let scope = match prefix.trim() {
1931 "user" => Some(QueryScope::User),
1932 "host" => Some(QueryScope::Hostname),
1933 "proxy" => Some(QueryScope::ProxyJump),
1934 "vault" => Some(QueryScope::VaultSsh),
1935 "tag" => Some(QueryScope::Tag),
1936 _ => None,
1937 };
1938 if scope.is_some() {
1939 return (scope, rest.trim_start());
1940 }
1941 }
1942 (None, query)
1943}
1944
1945#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1946pub enum QueryScope {
1947 User,
1948 Hostname,
1949 ProxyJump,
1950 VaultSsh,
1951 Tag,
1952}
1953
1954fn preview(s: &str, max: usize) -> String {
1956 let s = s.replace('\n', " ");
1957 let chars: Vec<char> = s.chars().collect();
1958 if chars.len() <= max {
1959 s
1960 } else {
1961 let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
1962 out.push_str("...");
1963 out
1964 }
1965}
1966
1967fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
1972 let scope = scope?;
1973 match (hit, scope) {
1974 (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
1975 (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
1976 Some(vec![&h.hostname])
1977 }
1978 (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
1979 Some(vec![&h.proxy_jump])
1980 }
1981 (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
1982 (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
1983 _ => None,
1985 }
1986}
1987
1988pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
1993 if query.is_empty() {
1994 return None;
1995 }
1996 let q = query.to_lowercase();
1997 let alias_hit = host.alias.to_lowercase().contains(&q);
1998 let hostname_hit = host.hostname.to_lowercase().contains(&q);
1999 if alias_hit || hostname_hit {
2000 return None;
2001 }
2002 if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
2003 return Some(MatchSource::User);
2004 }
2005 if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
2006 return Some(MatchSource::ProxyJump);
2007 }
2008 if let Some(role) = &host.vault_ssh
2009 && role.to_lowercase().contains(&q)
2010 {
2011 return Some(MatchSource::VaultSsh);
2012 }
2013 if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
2014 return Some(MatchSource::IdentityFile);
2015 }
2016 None
2017}
2018
2019#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2020pub enum MatchSource {
2021 User,
2022 ProxyJump,
2023 VaultSsh,
2024 IdentityFile,
2025}
2026
2027fn kind_rank(k: SourceKind) -> u8 {
2028 match k {
2029 SourceKind::Host => 0,
2030 SourceKind::Tunnel => 1,
2031 SourceKind::Container => 2,
2032 SourceKind::Snippet => 3,
2033 SourceKind::Action => 4,
2034 }
2035}
2036
2037fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
2042 if let Some(target) = prior
2043 && let Some(idx) = hits.iter().position(|h| &h.identity() == target)
2044 {
2045 return idx;
2046 }
2047 fallback.min(hits.len().saturating_sub(1))
2048}
2049
2050impl JumpAction {
2051 #[cfg(test)]
2052 pub fn all() -> &'static [JumpAction] {
2053 ALL_JUMP_ACTIONS
2054 }
2055
2056 pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
2060 ALL_JUMP_ACTIONS
2061 }
2062}
2063
2064#[cfg(test)]
2065mod tests;