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