Skip to main content

purple_ssh/
app.rs

1use ratatui::widgets::ListState;
2
3use crate::history::ConnectionHistory;
4use crate::ssh_config::model::SshConfigFile;
5
6/// Case-insensitive substring check without allocation.
7/// Uses a byte-window approach for ASCII strings (the common case for SSH
8/// hostnames and aliases). Falls back to a char-based scan when either
9/// string contains non-ASCII bytes to avoid false matches across UTF-8
10/// character boundaries.
11pub(super) fn contains_ci(haystack: &str, needle: &str) -> bool {
12    if needle.is_empty() {
13        return true;
14    }
15    if haystack.is_ascii() && needle.is_ascii() {
16        return haystack
17            .as_bytes()
18            .windows(needle.len())
19            .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()));
20    }
21    // Non-ASCII fallback: compare char-by-char (case fold ASCII only)
22    let needle_lower: Vec<char> = needle.chars().map(|c| c.to_ascii_lowercase()).collect();
23    let haystack_chars: Vec<char> = haystack.chars().collect();
24    haystack_chars.windows(needle_lower.len()).any(|window| {
25        window
26            .iter()
27            .zip(needle_lower.iter())
28            .all(|(h, n)| h.to_ascii_lowercase() == *n)
29    })
30}
31
32/// Case-insensitive equality check without allocation.
33pub(super) fn eq_ci(a: &str, b: &str) -> bool {
34    a.eq_ignore_ascii_case(b)
35}
36
37mod baselines;
38mod container_state;
39mod containers_overview;
40mod display_list;
41mod file_browser_state;
42mod form_state;
43mod forms;
44mod groups;
45mod host_state;
46mod hosts;
47pub(crate) use hosts::migrate_renames_persistent_state;
48pub(crate) mod jump;
49mod key_push_state;
50mod keys_state;
51mod pickers;
52pub(crate) mod ping;
53mod provider_state;
54mod reload_state;
55mod screen;
56mod search;
57mod selection;
58mod snippet_state;
59mod status_state;
60mod tag_state;
61mod tunnel_state;
62mod ui_state;
63mod update;
64mod vault;
65
66pub use baselines::{FormBaseline, ProviderFormBaseline, SnippetFormBaseline, TunnelFormBaseline};
67pub use container_state::{ContainerSession, ContainerState};
68pub use containers_overview::{
69    ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest, ContainersOverviewState,
70    ContainersSortMode, InspectCacheEntry, LIST_CACHE_TTL_SECS, LOGS_TAIL, LogsCacheEntry,
71    REFRESH_MAX_PARALLEL, RefreshBatch, RefreshQueueItem,
72};
73pub use file_browser_state::FileBrowserState;
74pub use form_state::FormState;
75pub(crate) use forms::char_to_byte_pos;
76pub use forms::{
77    FormField, HostForm, ProviderFormField, ProviderFormFields, SnippetForm, SnippetFormField,
78    SnippetHostOutput, SnippetOutputState, SnippetParamFormState, TunnelForm, TunnelFormField,
79};
80pub use host_state::{
81    DeletedHost, GroupBy, HostListItem, HostState, ProxyJumpCandidate, SortMode, ViewMode,
82    health_summary_spans, health_summary_spans_for,
83};
84pub use key_push_state::KeyPushState;
85pub use keys_state::KeysState;
86pub use ping::{
87    PingState, PingStatus, classify_ping, ping_sort_key, propagate_ping_to_dependents, status_glyph,
88};
89pub use provider_state::{
90    LabelMigrationField, PendingLabelMigration, ProviderRow, ProviderState, SyncRecord,
91};
92pub use reload_state::{ConflictState, ReloadState};
93pub use screen::{ContainerLogsSearch, Screen, StackMember, TopPage, WhatsNewState};
94pub use search::SearchState;
95pub use snippet_state::SnippetState;
96pub use status_state::{MessageClass, StatusCenter, StatusMessage};
97pub use tag_state::{
98    BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, TagState,
99    select_display_tags,
100};
101pub use tunnel_state::{TunnelSortMode, TunnelState};
102pub use ui_state::UiSelection;
103pub use update::UpdateState;
104pub use vault::VaultState;
105
106/// Kill active tunnel processes when App is dropped (e.g. on panic).
107impl Drop for App {
108    fn drop(&mut self) {
109        for (alias, mut tunnel) in self.tunnels.active.drain() {
110            if let Err(e) = tunnel.child.kill() {
111                log::debug!("[external] Failed to kill tunnel for {alias} on shutdown: {e}");
112            }
113            let _ = tunnel.child.wait();
114        }
115        // Cancel and join any in-flight Vault SSH bulk-sign worker so it
116        // cannot keep writing to ~/.purple/certs/ after teardown (panic
117        // unwind, normal exit, etc.).
118        if let Some(ref cancel) = self.vault.signing_cancel {
119            cancel.store(true, std::sync::atomic::Ordering::Relaxed);
120        }
121        if let Some(handle) = self.vault.sign_thread.take() {
122            let _ = handle.join();
123        }
124        // Same dance for key-push workers: signal cancel, join, so a
125        // panic or early exit cannot leave a thread writing to remote
126        // authorized_keys after the App is gone.
127        self.keys.push.shutdown();
128    }
129}
130
131/// Main application state.
132pub struct App {
133    // Core
134    /// Currently rendered screen identifier; navigation only, never carries state heaps.
135    pub screen: Screen,
136    /// Top-level page (Hosts, Tunnels, Containers). Selected by Tab/Shift+Tab
137    /// in the navigation bar. Independent of `screen`, which tracks overlays.
138    pub top_page: TopPage,
139    /// App lifecycle flag; flip to false to exit the event loop.
140    pub running: bool,
141    /// All host entries plus selection state.
142    pub(crate) hosts_state: HostState,
143
144    // Sub-structs
145    /// Toast queue, sticky messages, status routing.
146    pub status_center: StatusCenter,
147    /// Cursor reveal, detail-toggle, welcome timestamps and overlay meta.
148    pub(crate) ui: UiSelection,
149    /// Host-list incremental search query and matched hits.
150    pub search: SearchState,
151    /// Reload-from-disk state when ~/.ssh/config changes externally.
152    pub reload: ReloadState,
153    /// Conflict detection when an external edit clashes with our pending write.
154    pub conflict: ConflictState,
155
156    /// Keys-tab state: discovered keys, push runs, activity log.
157    pub(crate) keys: KeysState,
158
159    /// Tag library and per-host tag mappings.
160    pub tags: TagState,
161
162    /// Host form and bulk tag editor scratch state.
163    pub(crate) forms: FormState,
164
165    /// Connection history persisted to ~/.purple/history.
166    pub history: ConnectionHistory,
167
168    /// Provider configs, sync runs, host conflict resolution.
169    pub(crate) providers: ProviderState,
170
171    /// Ping/health-check state per host.
172    pub(crate) ping: PingState,
173
174    /// Vault SSH certificate cache and signing run state.
175    pub vault: VaultState,
176
177    /// Tunnel definitions per host and active tunnel processes.
178    pub(crate) tunnels: TunnelState,
179
180    /// Snippet library, parameter forms, output buffers.
181    pub(crate) snippets: SnippetState,
182
183    /// Self-update polling and badge state.
184    pub update: UpdateState,
185
186    /// askpass session token; not Keys-tab state.
187    pub bw_session: Option<String>,
188
189    // File browser
190    /// Persistent per-host last-visited paths; always present.
191    pub file_browser_state: FileBrowserState,
192    /// Per-host overlay session; Some when the file browser is open.
193    pub file_browser_session: Option<crate::file_browser::FileBrowserSession>,
194
195    // Containers
196    /// Cache and cross-host pending operations; always present.
197    pub(crate) container_state: ContainerState,
198    /// Per-host overlay session state; Some when the containers overlay is open.
199    pub(crate) container_session: Option<ContainerSession>,
200    /// Containers tab data: per-host docker ps cache, selection.
201    pub(crate) containers_overview: ContainersOverviewState,
202
203    /// Demo mode: all mutations are in-memory only, no disk writes.
204    pub demo_mode: bool,
205
206    /// Jump state. Some when the jump bar is open.
207    pub jump: Option<JumpState>,
208}
209
210impl App {
211    pub fn new(config: SshConfigFile) -> Self {
212        let hosts = config.host_entries();
213        let patterns = config.pattern_entries();
214        let display_list = Self::build_display_list_from(&config, &hosts, &patterns);
215
216        let initial_selection = display_list.iter().position(|item| {
217            matches!(
218                item,
219                HostListItem::Host { .. } | HostListItem::Pattern { .. }
220            )
221        });
222
223        let reload = ReloadState::from_config(&config);
224        let hosts_state = HostState::from_config(config, hosts, patterns, display_list);
225
226        Self {
227            screen: Screen::HostList,
228            top_page: TopPage::default(),
229            running: true,
230            hosts_state,
231            status_center: StatusCenter::default(),
232            ui: UiSelection::new_with_initial_selection(initial_selection),
233            search: SearchState::default(),
234            reload,
235            conflict: ConflictState::default(),
236            keys: KeysState {
237                list: Vec::new(),
238                list_state: ratatui::widgets::ListState::default(),
239                activity: crate::key_activity::KeyActivityLog::load(),
240                push: KeyPushState::default(),
241            },
242            tags: TagState::default(),
243            forms: FormState::default(),
244            history: ConnectionHistory::load(),
245            providers: ProviderState::load(),
246            ping: PingState::from_preferences(),
247            vault: VaultState::default(),
248            tunnels: TunnelState::default(),
249            snippets: SnippetState::with_store_loaded(),
250            update: UpdateState::with_current_hint(),
251            bw_session: None,
252            file_browser_state: FileBrowserState::default(),
253            file_browser_session: None,
254            container_state: ContainerState {
255                cache: crate::containers::load_container_cache(),
256                ..ContainerState::default()
257            },
258            container_session: None,
259            containers_overview: ContainersOverviewState::default(),
260            demo_mode: false,
261            jump: None,
262        }
263    }
264
265    /// Record an SSH session against `alias` in the activity log. Appends
266    /// in memory and flushes to `~/.purple/key_activity.json`. Failures
267    /// during flush are logged at debug level only; an activity-log write
268    /// failure must never interrupt the user's connect flow. Caller
269    /// passes `now`; production call sites pass `key_activity::now_secs()`.
270    pub fn record_key_use(&mut self, alias: &str, now: u64) {
271        crate::key_activity::record_and_flush(&mut self.keys.activity, alias, now);
272    }
273
274    /// Snapshot the alias of every host currently loaded. Used as
275    /// the "before" set for `queue_new_aliases_since` after a
276    /// reload that may have added or removed hosts.
277    pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
278        self.hosts_state
279            .list
280            .iter()
281            .map(|h| h.alias.clone())
282            .collect()
283    }
284
285    /// Push aliases that are in the current host list but were NOT
286    /// in `before_aliases` to the auto-fetch queue. Sync handlers
287    /// and external-config-edit detection use this so only freshly
288    /// introduced hosts trigger an initial `docker ps`. pre-existing
289    /// cache-missing hosts are explicitly left alone.
290    pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
291        let new_aliases: Vec<String> = self
292            .hosts_state
293            .list
294            .iter()
295            .filter(|h| !before_aliases.contains(&h.alias))
296            .map(|h| h.alias.clone())
297            .collect();
298        for alias in new_aliases {
299            self.container_state.queue_fetch(alias);
300        }
301    }
302
303    /// Reload hosts from config.
304    pub fn reload_hosts(&mut self) {
305        let had_pending_vault_write = self.vault.pending_config_write;
306        // Synchronously flush any deferred vault config write before reloading,
307        // so on-disk state matches in-memory state (no TOCTOU with auto-reload).
308        // Skip when a form is open (flush handler would bail anyway) and do not
309        // call flush_pending_vault_write() itself to avoid recursion.
310        //
311        // Before flushing, check whether the on-disk config changed since the
312        // in-memory model was loaded. If so, the deferred write would overwrite
313        // those external edits silently. Surface a notification and skip the
314        // flush; the user can re-trigger vault sign after reviewing their
315        // changes. The cert files themselves were already written by the bulk
316        // sign worker — only the config-side `CertificateFile` directives are
317        // skipped, which the user can wire up via a fresh sign.
318        let mut flushed_vault_write = false;
319        if self.vault.pending_config_write && !self.is_form_open() {
320            if self.external_config_changed() {
321                self.notify_error(
322                    crate::messages::vault_config_skipped_external_change().to_string(),
323                );
324                log::warn!(
325                    "[config] reload_hosts: skipping deferred vault write — external config changed"
326                );
327            } else {
328                match self.hosts_state.ssh_config.write() {
329                    Ok(()) => flushed_vault_write = true,
330                    Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
331                }
332            }
333        }
334        // Always clear the flag: either we flushed, we surfaced a conflict, or
335        // the form-submit path has already written the full config.
336        self.vault.pending_config_write = false;
337        log::debug!(
338            "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
339        );
340        let had_search = self.search.query.take();
341        let selected_alias = self
342            .selected_host()
343            .map(|h| h.alias.clone())
344            .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
345
346        self.tunnels.summaries_cache.clear();
347        self.hosts_state.render_cache.invalidate();
348        self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
349        self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
350        // Prune cert status cache and in-flight set: retain only entries whose
351        // host alias still exists after the reload.
352        let valid_for_certs: std::collections::HashSet<&str> = self
353            .hosts_state
354            .list
355            .iter()
356            .map(|h| h.alias.as_str())
357            .collect();
358        self.vault
359            .cert_cache
360            .retain(|alias, _| valid_for_certs.contains(alias.as_str()));
361        self.vault
362            .cert_checks_in_flight
363            .retain(|alias| valid_for_certs.contains(alias.as_str()));
364        if self.hosts_state.sort_mode == SortMode::Original
365            && matches!(self.hosts_state.group_by, GroupBy::None)
366        {
367            self.hosts_state.display_list = Self::build_display_list_from(
368                &self.hosts_state.ssh_config,
369                &self.hosts_state.list,
370                &self.hosts_state.patterns,
371            );
372        } else {
373            self.apply_sort();
374        }
375
376        // Close tag pickers if open. tags.list is stale after reload
377        if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
378            self.set_screen(Screen::HostList);
379            self.forms.bulk_tag_editor = BulkTagEditorState::default();
380        }
381
382        // Multi-select stores indices into hosts; clear to avoid stale refs
383        self.hosts_state.multi_select.clear();
384
385        // Prune ping status for hosts that no longer exist
386        let valid_aliases: std::collections::HashSet<&str> = self
387            .hosts_state
388            .list
389            .iter()
390            .map(|h| h.alias.as_str())
391            .collect();
392
393        // Drop container-cache entries for hosts that disappeared
394        // since the last reload (manual delete, stale purge, or an
395        // external `~/.ssh/config` edit). Persist the trimmed cache
396        // so `~/.purple/container_cache.jsonl` does not keep
397        // serving orphan entries on the next purple start. Demo
398        // mode skips disk writes via `save_container_cache` itself.
399        let pre_container_cache = self.container_state.cache.len();
400        self.container_state
401            .cache
402            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
403        let dropped_container_hosts =
404            pre_container_cache.saturating_sub(self.container_state.cache.len());
405        if dropped_container_hosts > 0 {
406            log::debug!(
407                "[purple] reload_hosts: dropped {} orphan container_cache host(s)",
408                dropped_container_hosts
409            );
410            crate::containers::save_container_cache(&self.container_state.cache);
411        }
412
413        // Inspect cache is keyed on full container ID. Any ID whose
414        // host just got dropped is by definition orphan; build the
415        // valid-id set from the (just-pruned) container_cache.
416        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        let pre_inspect = self.containers_overview.inspect_cache.entries.len();
423        self.containers_overview
424            .inspect_cache
425            .entries
426            .retain(|id, _| valid_container_ids.contains(id));
427        self.containers_overview
428            .inspect_cache
429            .in_flight
430            .retain(|id| valid_container_ids.contains(id));
431        // Logs cache shares the inspect-cache lifetime: orphan entries
432        // (containers whose host was just removed) are dropped together.
433        self.containers_overview
434            .logs_cache
435            .entries
436            .retain(|id, _| valid_container_ids.contains(id));
437        self.containers_overview
438            .logs_cache
439            .in_flight
440            .retain(|id| valid_container_ids.contains(id));
441        // Prune auto-list in-flight markers for deleted hosts. The
442        // listing thread still posts a result that hits the race
443        // guard in `handle_container_listing` and removes it there,
444        // but pruning here keeps debug state clean and avoids a
445        // false-positive dedup hit if the same alias is re-added
446        // before the stray listing returns.
447        self.containers_overview
448            .auto_list_in_flight
449            .retain(|alias| valid_aliases.contains(alias.as_str()));
450        // Container-overview refresh batch (R). Tracks in-flight aliases to
451        // gate counter updates against non-batch listings. Prune so that a
452        // host removed mid-batch cannot linger.
453        if let Some(batch) = self.containers_overview.refresh_batch.as_mut() {
454            let pre = batch.in_flight_aliases.len();
455            batch
456                .in_flight_aliases
457                .retain(|alias| valid_aliases.contains(alias.as_str()));
458            let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
459            if dropped > 0 {
460                log::debug!(
461                    "[purple] reload_hosts: dropped {} orphan refresh_batch in_flight alias(es)",
462                    dropped
463                );
464            }
465        }
466        // Bulk vault-sign tracker. Worker self-prunes its own entries via
467        // `remove_in_flight`, but a host removed mid-sign would linger. On
468        // poison recover via `into_inner` instead of dropping the work. A
469        // poisoned worker still owns live aliases that must not be cleared.
470        {
471            let mut sign = match self.vault.sign_in_flight.lock() {
472                Ok(g) => g,
473                Err(p) => p.into_inner(),
474            };
475            let pre = sign.len();
476            sign.retain(|alias| valid_aliases.contains(alias.as_str()));
477            let dropped = pre.saturating_sub(sign.len());
478            if dropped > 0 {
479                log::debug!(
480                    "[purple] reload_hosts: dropped {} orphan sign_in_flight alias(es)",
481                    dropped
482                );
483            }
484        }
485        // Per-host last-visited file-browser path. Pure host-keyed state
486        // with no self-pruning, so a rename leaves the old alias behind.
487        let pre_paths = self.file_browser_state.host_paths.len();
488        self.file_browser_state
489            .host_paths
490            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
491        let dropped_paths = pre_paths.saturating_sub(self.file_browser_state.host_paths.len());
492        if dropped_paths > 0 {
493            log::debug!(
494                "[purple] reload_hosts: dropped {} orphan file_browser host_paths entrie(s)",
495                dropped_paths
496            );
497        }
498        // Demo-mode tunnel snapshot seed. The detail panel reads from this
499        // map when `demo_mode == true`. Outside demo it stays empty, but a
500        // demo workflow that renames or deletes a host should not leak.
501        let pre_demo = self.tunnels.demo_live_snapshots.len();
502        self.tunnels
503            .demo_live_snapshots
504            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
505        let dropped_demo = pre_demo.saturating_sub(self.tunnels.demo_live_snapshots.len());
506        if dropped_demo > 0 {
507            log::debug!(
508                "[purple] reload_hosts: dropped {} orphan demo_live_snapshots entrie(s)",
509                dropped_demo
510            );
511        }
512        // Containers-overview collapsed groups. Persisted to disk via
513        // preferences, so leftover aliases survive restart. Rename is
514        // already handled by `apply_alias_renames`; this covers delete.
515        let pre_collapsed = self.containers_overview.collapsed_hosts.len();
516        self.containers_overview
517            .collapsed_hosts
518            .retain(|alias| valid_aliases.contains(alias.as_str()));
519        let dropped_collapsed =
520            pre_collapsed.saturating_sub(self.containers_overview.collapsed_hosts.len());
521        if dropped_collapsed > 0 {
522            log::debug!(
523                "[purple] reload_hosts: dropped {} orphan collapsed_hosts entrie(s)",
524                dropped_collapsed
525            );
526            if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
527                &self.containers_overview.collapsed_hosts,
528            ) {
529                log::warn!("[config] failed to save collapsed_hosts after prune: {e}");
530            }
531        }
532        let dropped_inspect =
533            pre_inspect.saturating_sub(self.containers_overview.inspect_cache.entries.len());
534        if dropped_inspect > 0 {
535            log::debug!(
536                "[purple] reload_hosts: dropped {} orphan inspect_cache entrie(s)",
537                dropped_inspect
538            );
539        }
540
541        let pre_status = self.ping.status.len();
542        let pre_checked = self.ping.last_checked.len();
543        self.ping
544            .status
545            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
546        self.ping
547            .last_checked
548            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
549        let dropped = pre_status.saturating_sub(self.ping.status.len())
550            + pre_checked.saturating_sub(self.ping.last_checked.len());
551        if dropped > 0 {
552            log::debug!(
553                "[purple] reload_hosts: pruned {} orphan ping entrie(s); {} aliases remain",
554                dropped,
555                valid_aliases.len()
556            );
557        }
558
559        // Restore search if it was active, otherwise reset
560        if let Some(query) = had_search {
561            self.search.query = Some(query);
562            self.apply_filter();
563        } else {
564            self.search.query = None;
565            self.search.filtered_indices.clear();
566            self.search.filtered_pattern_indices.clear();
567            // Fix selection for display list mode
568            if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
569                self.ui.list_state.select(None);
570            } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
571                matches!(
572                    item,
573                    HostListItem::Host { .. } | HostListItem::Pattern { .. }
574                )
575            }) {
576                let current = self.ui.list_state.selected().unwrap_or(0);
577                if current >= self.hosts_state.display_list.len()
578                    || !matches!(
579                        self.hosts_state.display_list.get(current),
580                        Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
581                    )
582                {
583                    self.ui.list_state.select(Some(pos));
584                }
585            } else {
586                self.ui.list_state.select(None);
587            }
588        }
589
590        // Restore selection by alias (e.g. after SSH connect changed sort order)
591        if let Some(alias) = selected_alias {
592            self.select_host_by_alias(&alias);
593        }
594
595        log::debug!(
596            "[config] reload_hosts: hosts={} patterns={} display_items={}",
597            self.hosts_state.list.len(),
598            self.hosts_state.patterns.len(),
599            self.hosts_state.display_list.len(),
600        );
601    }
602
603    /// Synchronously re-check a host's Vault SSH certificate and update
604    /// `vault.cert_cache` with fresh status + on-disk mtime.
605    ///
606    /// Every sign path (V-key bulk sign, host form submit, connect-time
607    /// `ensure_vault_ssh_if_needed`, CLI) funnels through this helper so the
608    /// detail panel never lies about cert state after a successful sign.
609    ///
610    /// No-op in demo mode. If the host is missing, has no resolvable vault
611    /// role, or the cert path cannot be resolved, any stale entry for the
612    /// alias is removed to avoid showing ghost status.
613    pub fn refresh_cert_cache(&mut self, alias: &str) {
614        if crate::demo_flag::is_demo() {
615            return;
616        }
617        let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
618            self.vault.cert_cache.remove(alias);
619            return;
620        };
621        let role_some = crate::vault_ssh::resolve_vault_role(
622            host.vault_ssh.as_deref(),
623            host.provider.as_deref(),
624            host.provider_label.as_deref(),
625            &self.providers.config,
626        )
627        .is_some();
628        if !role_some {
629            self.vault.cert_cache.remove(alias);
630            return;
631        }
632        let cert_path = match crate::vault_ssh::resolve_cert_path(alias, &host.certificate_file) {
633            Ok(p) => p,
634            Err(_) => {
635                self.vault.cert_cache.remove(alias);
636                return;
637            }
638        };
639        let status = crate::vault_ssh::check_cert_validity(&cert_path);
640        let mtime = std::fs::metadata(&cert_path)
641            .ok()
642            .and_then(|m| m.modified().ok());
643        self.vault.cert_cache.insert(
644            alias.to_string(),
645            (std::time::Instant::now(), status, mtime),
646        );
647    }
648
649    // --- Search methods ---
650
651    /// Shim. Routes to `ProviderState::sorted_names`.
652    /// Test-only: production code uses `provider_list_rows()` for the
653    /// tree-style list, so this wrapper exists to keep older test fixtures
654    /// concise.
655    #[cfg(test)]
656    pub fn sorted_provider_names(&self) -> Vec<String> {
657        self.providers.sorted_names()
658    }
659
660    /// Check whether a form screen is currently open (host or provider forms).
661    pub fn is_form_open(&self) -> bool {
662        matches!(
663            self.screen,
664            Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
665        )
666    }
667
668    /// Open the unified jump in the given mode. Loads recents
669    /// from disk and seeds the empty-query view. Recomputes hits.
670    pub fn open_jump(&mut self, mode: JumpMode) {
671        log::debug!("jump: open mode={:?}", mode);
672        let mut state = JumpState::for_mode(mode);
673        let recents_file = jump::load_recents();
674        state.recents = self.resolve_recents(&recents_file);
675        self.jump = Some(state);
676        self.recompute_jump_hits();
677    }
678
679    /// Translate the on-disk recents log into live `JumpHit`s, dropping
680    /// dangling references silently.
681    fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
682        let mode = self
683            .jump
684            .as_ref()
685            .map(|p| p.mode)
686            .unwrap_or(JumpMode::Hosts);
687        let mut out = Vec::with_capacity(file.entries.len());
688        for entry in &file.entries {
689            if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
690                out.push(hit);
691            }
692        }
693        out
694    }
695
696    /// Test seam: exposes `resolve_recent_ref` as `pub(crate)` so the unit
697    /// tests in `app::tests` can drive each `SourceKind` branch without
698    /// going through `open_jump`.
699    #[cfg(test)]
700    pub(crate) fn resolve_recent_ref_for_test(
701        &self,
702        r: &RecentRef,
703        mode: JumpMode,
704    ) -> Option<JumpHit> {
705        self.resolve_recent_ref(r, mode)
706    }
707
708    fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
709        match r.kind {
710            SourceKind::Action => {
711                let key_char = r.key.chars().next()?;
712                let actions = JumpAction::for_mode(mode);
713                actions
714                    .iter()
715                    .find(|a| a.key == key_char)
716                    .copied()
717                    .map(JumpHit::Action)
718            }
719            SourceKind::Host => {
720                let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
721                Some(JumpHit::Host(HostHit {
722                    alias: host.alias.clone(),
723                    hostname: host.hostname.clone(),
724                    tags: host.tags.clone(),
725                    provider: host.provider.clone(),
726                    user: host.user.clone(),
727                    identity_file: host.identity_file.clone(),
728                    proxy_jump: host.proxy_jump.clone(),
729                    vault_ssh: host.vault_ssh.clone(),
730                }))
731            }
732            SourceKind::Tunnel => {
733                let (alias, port_str) = r.key.split_once(':')?;
734                let port: u16 = port_str.parse().ok()?;
735                let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
736                let rule = rules.iter().find(|r| r.bind_port == port)?;
737                Some(JumpHit::Tunnel(TunnelHit {
738                    alias: alias.to_string(),
739                    bind_port: rule.bind_port,
740                    bind_port_str: rule.bind_port.to_string(),
741                    destination: rule.display(),
742                    active: self.tunnels.active.contains_key(alias),
743                }))
744            }
745            SourceKind::Container => {
746                let (alias, name) = r.key.split_once('/')?;
747                let entry = self.container_state.cache.get(alias)?;
748                let info = entry.containers.iter().find(|c| c.names == name)?;
749                Some(JumpHit::Container(ContainerHit {
750                    alias: alias.to_string(),
751                    container_name: info.names.clone(),
752                    container_id: info.id.clone(),
753                    state: info.state.clone(),
754                }))
755            }
756            SourceKind::Snippet => {
757                let snippet = self.snippets.store.get(&r.key)?;
758                Some(JumpHit::Snippet(SnippetHit {
759                    name: snippet.name.clone(),
760                    command_preview: preview(&snippet.command, 40),
761                }))
762            }
763        }
764    }
765
766    /// Recompute the jump bar hit list against the current query. Pulls
767    /// candidates from every live source and ranks them with nucleo-matcher.
768    /// Preserves the previously-selected hit's identity across the
769    /// recompute so mid-typing arrow-key navigation does not jump back to
770    /// row 0.
771    pub fn recompute_jump_hits(&mut self) {
772        let Some(mut state) = self.jump.take() else {
773            return;
774        };
775        // Identity of the row the user was on before the recompute. We
776        // re-resolve it after rebuilding `hits` to keep selection stable
777        // when the user types and the matched row is still in the list.
778        let prior_identity = state
779            .visible_hits()
780            .get(state.selected)
781            .map(|h| h.identity());
782
783        let candidates = self.collect_jump_candidates(state.mode);
784        if state.query.is_empty() {
785            state.hits = candidates;
786            state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
787            self.jump = Some(state);
788            return;
789        }
790
791        // Field-prefix syntax: `user:eric` scopes to one field. Empty
792        // remainder after the prefix is treated as no query (empty
793        // scope-search). Mode is held in `query_scope` for the row
794        // renderer to surface a "via <field>" hint.
795        let (scope, effective_query) = parse_query_scope(&state.query);
796
797        use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
798        use nucleo_matcher::{Config, Matcher, Utf32Str};
799        let matcher_state = state
800            .matcher
801            .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
802        let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
803        let mut buf: Vec<char> = Vec::new();
804        let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
805        for hit in candidates {
806            let mut best: u32 = 0;
807            // Score over the right haystack set: scoped queries narrow to
808            // a single field; unscoped queries score over everything the
809            // hit advertises.
810            let scoped_haystacks = scoped_haystacks_for(&hit, scope);
811            let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
812                hs
813            } else {
814                hit.haystacks()
815            };
816            for haystack in haystacks {
817                buf.clear();
818                let chars = Utf32Str::new(haystack, &mut buf);
819                if let Some(score) = pattern.score(chars, matcher_state) {
820                    best = best.max(score);
821                }
822            }
823            // Boost: a single-char query that exactly matches an action's
824            // hotkey letter (case-insensitive) lands the action at the top.
825            // When two actions share the same hotkey (e.g. 'a' for `Hosts:
826            // Add host` and `Tunnels: Add tunnel`), the one whose target
827            // matches the current mode wins, so muscle memory survives.
828            if let JumpHit::Action(a) = &hit {
829                let single = effective_query.chars().next();
830                if effective_query.chars().count() == 1
831                    && single
832                        .map(|c| c.eq_ignore_ascii_case(&a.key))
833                        .unwrap_or(false)
834                {
835                    let mode_match = matches!(
836                        (state.mode, a.target),
837                        (JumpMode::Hosts, JumpActionTarget::Hosts)
838                            | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
839                            | (JumpMode::Containers, JumpActionTarget::Containers)
840                            | (JumpMode::Keys, JumpActionTarget::Keys)
841                    );
842                    let bump = if mode_match { 20_000 } else { 10_000 };
843                    best = best.saturating_add(bump);
844                }
845            }
846            // Score floor: actions need to clear a higher bar than data
847            // rows. Stops query 'eric' from dragging in 'Containers: List
848            // containers' on stray e/r/i/c char overlap.
849            let floor = match &hit {
850                JumpHit::Action(_) => jump::PALETTE_ACTION_FLOOR,
851                _ => 1,
852            };
853            if best >= floor {
854                scored.push((hit, best));
855            }
856        }
857        // Stable sort: higher score first, ties broken by render-order kind so
858        // hosts come before actions when scores tie.
859        scored.sort_by(|a, b| {
860            b.1.cmp(&a.1)
861                .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
862        });
863        // Cap per-section using a fixed-size array so a broad query (one
864        // char that matches everything) cannot blow the visible list.
865        let mut per_kind: [usize; 5] = [0; 5];
866        let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
867        for (hit, _) in scored {
868            let slot = kind_rank(hit.kind()) as usize;
869            if per_kind[slot] < PALETTE_PER_SECTION_CAP {
870                per_kind[slot] += 1;
871                filtered.push(hit);
872            }
873        }
874        state.hits = filtered;
875        state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
876        self.jump = Some(state);
877    }
878
879    fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
880        let mut out: Vec<JumpHit> = Vec::new();
881        // Hosts
882        for h in &self.hosts_state.list {
883            out.push(JumpHit::Host(HostHit {
884                alias: h.alias.clone(),
885                hostname: h.hostname.clone(),
886                tags: h.tags.clone(),
887                provider: h.provider.clone(),
888                user: h.user.clone(),
889                identity_file: h.identity_file.clone(),
890                proxy_jump: h.proxy_jump.clone(),
891                vault_ssh: h.vault_ssh.clone(),
892            }));
893        }
894        // Tunnels: every configured rule from every host with a directive.
895        for h in &self.hosts_state.list {
896            let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
897            for rule in rules {
898                out.push(JumpHit::Tunnel(TunnelHit {
899                    alias: h.alias.clone(),
900                    bind_port: rule.bind_port,
901                    bind_port_str: rule.bind_port.to_string(),
902                    destination: rule.display(),
903                    active: self.tunnels.active.contains_key(&h.alias),
904                }));
905            }
906        }
907        // Containers: cached only. Triggering an SSH fetch on jump bar open
908        // would be unbounded latency.
909        for (alias, entry) in &self.container_state.cache {
910            for info in &entry.containers {
911                out.push(JumpHit::Container(ContainerHit {
912                    alias: alias.clone(),
913                    container_name: info.names.clone(),
914                    container_id: info.id.clone(),
915                    state: info.state.clone(),
916                }));
917            }
918        }
919        // Snippets
920        for snippet in &self.snippets.store.snippets {
921            out.push(JumpHit::Snippet(SnippetHit {
922                name: snippet.name.clone(),
923                command_preview: preview(&snippet.command, 40),
924            }));
925        }
926        // Actions last
927        for a in JumpAction::for_mode(mode) {
928            out.push(JumpHit::Action(*a));
929        }
930        out
931    }
932
933    /// Persist a jump dispatch to the on-disk MRU log. Best-effort; a
934    /// write error logs and is otherwise swallowed so user navigation is
935    /// never blocked by a recents-file failure. Takes `&mut self` so the
936    /// type system reflects that this performs I/O and mutates persistent
937    /// state, even though `jump::save_recents` only needs `&File`.
938    pub fn record_jump_hit(&mut self, hit: &JumpHit) {
939        if self.demo_mode {
940            log::debug!("jump: record skipped (demo mode)");
941            return;
942        }
943        let mut file = jump::load_recents();
944        jump::touch_recent(&mut file, hit.identity());
945        if let Err(e) = jump::save_recents(&file) {
946            log::warn!("[purple] failed to save recents: {e}");
947        }
948    }
949
950    /// Flush a deferred vault config write if one is pending and no form is open.
951    /// Returns true if a write was performed.
952    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        // reload_hosts() performs the write and clears the flag.
957        self.reload_hosts();
958        true
959    }
960
961    /// Run once after App::new: queue the upgrade toast if the user just
962    /// upgraded past their last-seen version, otherwise seed the preference
963    /// so the next launch is silent.
964    pub fn post_init(&mut self) {
965        let outcome = crate::onboarding::evaluate();
966        if let Some(text) = outcome.upgrade_toast {
967            self.enqueue_sticky_toast(text);
968        }
969        // Seed the Keys tab so the first Tab navigation lands on a
970        // populated list. Subsequent reloads run via R or after a host
971        // form save / provider sync.
972        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    /// User action feedback. Success toast, length-proportional timeout.
988    pub fn notify(&mut self, text: impl Into<String>) {
989        self.status_center.set_status(text, false);
990    }
991
992    /// User action error. Error toast, sticky by default, queued.
993    pub fn notify_error(&mut self, text: impl Into<String>) {
994        self.status_center.set_status(text, true);
995    }
996
997    /// Background event. Info footer, suppressed if sticky active.
998    pub fn notify_background(&mut self, text: impl Into<String>) {
999        self.status_center.set_background_status(text, false);
1000    }
1001
1002    /// Background error. Sticky toast, bypasses sticky suppression.
1003    pub fn notify_background_error(&mut self, text: impl Into<String>) {
1004        self.status_center.set_background_status(text, true);
1005    }
1006
1007    /// Caution / degraded state → Warning toast (length-proportional
1008    /// timeout, queued). For: precondition violations ("Nothing to undo."),
1009    /// validation hints ("Project ID can't be empty."), empty-state
1010    /// notices ("No stale hosts."), stale-host warnings, deprecated
1011    /// config detected, partial sync results. Warnings are NOT sticky;
1012    /// the user acknowledges them by continuing to interact.
1013    ///
1014    /// Use `notify_error` only for system-level failures (I/O, network,
1015    /// subprocess) that require explicit acknowledgement. Use
1016    /// `notify_warning` for everything that is "this can't happen given
1017    /// current state" or "you forgot something".
1018    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!("toast <- Warning: {}", msg.text);
1027        self.status_center.push_toast(msg);
1028    }
1029
1030    /// Long-running progress. Footer sticky, never expires automatically.
1031    pub fn notify_progress(&mut self, text: impl Into<String>) {
1032        self.status_center.set_sticky_status(text, false);
1033    }
1034
1035    /// Sticky error. Footer sticky, never expires automatically.
1036    pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
1037        self.status_center.set_sticky_status(text, true);
1038    }
1039
1040    /// Explicit info. Footer, 4s timeout, not suppressed by sticky.
1041    pub fn notify_info(&mut self, text: impl Into<String>) {
1042        self.status_center.set_info_status(text);
1043    }
1044
1045    /// Tick the footer status message timer. Uses wall-clock time.
1046    /// Sticky/Progress messages never expire automatically.
1047    ///
1048    /// Stays on `App` (not moved to `StatusCenter`) because expiry is
1049    /// suppressed while any provider sync is in flight, which requires
1050    /// reading `self.providers.syncing`.
1051    pub fn tick_status(&mut self) {
1052        // Don't expire status while providers are still syncing
1053        if !self.providers.syncing.is_empty() {
1054            return;
1055        }
1056        if let Some(ref status) = self.status_center.status {
1057            if status.sticky {
1058                return;
1059            }
1060            let timeout_ms = status.timeout_ms();
1061            if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
1062            {
1063                log::debug!("footer status expired: {}", status.text);
1064                self.status_center.status = None;
1065            }
1066        }
1067    }
1068
1069    /// Shim. Routes to `StatusCenter::tick_toast`.
1070    pub fn tick_toast(&mut self) {
1071        self.status_center.tick_toast();
1072    }
1073
1074    /// Check if config or any Include file has changed externally and reload if so.
1075    /// Skips reload when the user is in a form (AddHost/EditHost) to avoid
1076    /// overwriting in-memory config while the user is editing.
1077    pub fn check_config_changed(&mut self) {
1078        if matches!(
1079            self.screen,
1080            Screen::AddHost
1081                | Screen::EditHost { .. }
1082                | Screen::ProviderForm { .. }
1083                | Screen::TunnelList { .. }
1084                | Screen::TunnelForm { .. }
1085                | Screen::HostDetail { .. }
1086                | Screen::SnippetPicker { .. }
1087                | Screen::SnippetForm { .. }
1088                | Screen::SnippetOutput { .. }
1089                | Screen::SnippetParamForm { .. }
1090                | Screen::FileBrowser { .. }
1091                | Screen::Containers { .. }
1092                | Screen::ConfirmDelete { .. }
1093                | Screen::ConfirmHostKeyReset { .. }
1094                | Screen::ConfirmPurgeStale { .. }
1095                | Screen::ConfirmImport { .. }
1096                | Screen::ConfirmVaultSign { .. }
1097                | Screen::TagPicker
1098                | Screen::BulkTagEditor
1099                | Screen::ThemePicker
1100                | Screen::WhatsNew(_)
1101        ) || self.tags.input.is_some()
1102        {
1103            return;
1104        }
1105        let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1106        let changed = current_mtime != self.reload.last_modified
1107            || self
1108                .reload
1109                .include_mtimes
1110                .iter()
1111                .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1112            || self
1113                .reload
1114                .include_dir_mtimes
1115                .iter()
1116                .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
1117        if changed {
1118            log::debug!(
1119                "[config] check_config_changed: mtime drift detected on {} -> reloading",
1120                self.reload.config_path.display()
1121            );
1122            if let Ok(new_config) = SshConfigFile::parse(&self.reload.config_path) {
1123                let before_aliases = self.snapshot_alias_set();
1124                self.hosts_state.ssh_config = new_config;
1125                // Invalidate undo state. config structure may have changed externally
1126                self.hosts_state.undo_stack.clear();
1127                // Clear stale ping status. hosts may have changed
1128                log::debug!(
1129                    "[config] external config change: clearing {} ping result(s) + timestamps",
1130                    self.ping.status.len()
1131                );
1132                self.ping.status.clear();
1133                self.ping.last_checked.clear();
1134                self.ping.filter_down_only = false;
1135                self.ping.checked_at = None;
1136                self.reload_hosts();
1137                self.reload.last_modified = current_mtime;
1138                self.reload.include_mtimes =
1139                    reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1140                self.reload.include_dir_mtimes =
1141                    reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1142                let count = self.hosts_state.list.len();
1143                self.notify_background(crate::messages::config_reloaded(count));
1144                self.queue_new_aliases_since(&before_aliases);
1145            }
1146        }
1147    }
1148
1149    /// Detect external changes to `~/.ssh/` keys and refresh `self.keys.list`
1150    /// when something has moved. Mirrors `check_config_changed` for the
1151    /// keys tab so users see new key files (or deletions, or rotations)
1152    /// without pressing R. Cheap: a single dir stat plus one stat per
1153    /// tracked key. Called from the 4-second throttle in `handle_tick`.
1154    ///
1155    /// Skips during demo mode (the demo seeds a fixed key list and never
1156    /// reads from disk) and when a form is open that could be mutating
1157    /// the same data.
1158    pub fn check_keys_changed(&mut self) {
1159        if self.demo_mode {
1160            return;
1161        }
1162        if matches!(
1163            self.screen,
1164            Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
1165        ) {
1166            return;
1167        }
1168        let Some(home) = dirs::home_dir() else {
1169            return;
1170        };
1171        let ssh_dir = home.join(".ssh");
1172        let current_dir_mtime = reload_state::get_mtime(&ssh_dir);
1173        let dir_changed = current_dir_mtime != self.reload.keys_dir_mtime;
1174        let files_changed = self
1175            .reload
1176            .key_file_mtimes
1177            .iter()
1178            .any(|(path, old)| reload_state::get_mtime(path) != *old);
1179        if !dir_changed && !files_changed {
1180            return;
1181        }
1182        log::debug!(
1183            "[purple] check_keys_changed: drift detected on {} (dir={} files={}) -> rescan",
1184            ssh_dir.display(),
1185            dir_changed,
1186            files_changed,
1187        );
1188        let previous = self.keys.list.len();
1189        self.scan_keys();
1190        let after = self.keys.list.len();
1191        // Keep the selection valid after a rescan: clamp to the new list
1192        // length, or land on the first row when the list grew from empty.
1193        if let Some(sel) = self.keys.list_state.selected() {
1194            if sel >= after {
1195                let next = after.checked_sub(1);
1196                self.keys.list_state.select(next);
1197            }
1198        } else if after > 0 {
1199            self.keys.list_state.select(Some(0));
1200        }
1201        if previous != after {
1202            log::debug!(
1203                "[purple] check_keys_changed: rescan {} -> {} keys",
1204                previous,
1205                after
1206            );
1207        }
1208    }
1209
1210    /// Non-mutating check: has the on-disk config (or any tracked Include)
1211    /// been modified since `self.reload.last_modified` was captured? Used by
1212    /// async write paths (e.g. the Vault SSH bulk-sign completion handler)
1213    /// to refuse writing when an external editor changed the file underneath
1214    /// us. overwriting those edits would silently discard user work. The
1215    /// backup-on-write mechanism in `SshConfigFile::write()` would still
1216    /// recover them, but detecting the conflict BEFORE writing is strictly
1217    /// better than after.
1218    pub fn external_config_changed(&self) -> bool {
1219        let current_mtime = reload_state::get_mtime(&self.reload.config_path);
1220        current_mtime != self.reload.last_modified
1221            || self
1222                .reload
1223                .include_mtimes
1224                .iter()
1225                .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1226            || self
1227                .reload
1228                .include_dir_mtimes
1229                .iter()
1230                .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
1231    }
1232
1233    /// Update the last_modified timestamp (call after writing config).
1234    pub fn update_last_modified(&mut self) {
1235        self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
1236        self.reload.include_mtimes =
1237            reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
1238        self.reload.include_dir_mtimes =
1239            reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
1240    }
1241
1242    /// Returns true if any host or provider has a vault role configured.
1243    pub fn has_any_vault_role(&self) -> bool {
1244        for host in &self.hosts_state.list {
1245            if host.vault_ssh.is_some() {
1246                return true;
1247            }
1248        }
1249        for section in &self.providers.config.sections {
1250            if !section.vault_role.is_empty() {
1251                return true;
1252            }
1253        }
1254        false
1255    }
1256
1257    /// Poll active tunnels for exit. Returns (alias, message, is_error) tuples.
1258    pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
1259        self.tunnels.poll()
1260    }
1261
1262    /// Recompute the lsof poller's bind-port list from the current
1263    /// `active` map plus each host's directives in the SSH config.
1264    /// Called after every tunnel start/stop. The poller picks up the
1265    /// new list on its next iteration.
1266    pub fn refresh_tunnel_bind_ports(&mut self) {
1267        let mut ports: Vec<(String, u16, u32)> = Vec::new();
1268        for (alias, tunnel) in &self.tunnels.active {
1269            let pid = tunnel.child.id();
1270            for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
1271                ports.push((alias.clone(), rule.bind_port, pid));
1272            }
1273        }
1274        self.tunnels.set_lsof_ports(ports);
1275    }
1276}
1277
1278/// Cycle list selection forward or backward with wraparound.
1279pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
1280    if len == 0 {
1281        return;
1282    }
1283    let i = match state.selected() {
1284        Some(i) => {
1285            if forward {
1286                if i >= len - 1 { 0 } else { i + 1 }
1287            } else if i == 0 {
1288                len - 1
1289            } else {
1290                i - 1
1291            }
1292        }
1293        None => 0,
1294    };
1295    state.select(Some(i));
1296}
1297
1298/// Jump forward by page_size items, clamping at the end (no wrap).
1299pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
1300    if len == 0 {
1301        return;
1302    }
1303    let current = state.selected().unwrap_or(0);
1304    let next = (current + page_size).min(len - 1);
1305    state.select(Some(next));
1306}
1307
1308/// Jump backward by page_size items, clamping at 0 (no wrap).
1309pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
1310    if len == 0 {
1311        return;
1312    }
1313    let current = state.selected().unwrap_or(0);
1314    let prev = current.saturating_sub(page_size);
1315    state.select(Some(prev));
1316}
1317
1318// Re-export the jump bar types so call sites keep referring to them via
1319// `crate::app::JumpHit` / `crate::app::JumpAction` without caring
1320// which submodule they live in.
1321pub use jump::{
1322    ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, JumpMode, JumpState, RecentRef,
1323    RecentsFile, SnippetHit, SourceKind, TunnelHit,
1324};
1325
1326/// Backwards-compatible alias for the old `PaletteCommand` (now `JumpAction`) name. The
1327/// renamed type is `JumpAction`. Test-only. there is no production
1328/// caller.
1329#[cfg(test)]
1330pub type PaletteCommand = JumpAction;
1331
1332/// Unified action set. Every action declares its `target` so dispatch
1333/// switches `top_page` first, then synthesises the hotkey for the right
1334/// handler. The jump bar shows this same list regardless of which
1335/// top-page was active when it opened. so the overlay size is
1336/// consistent and `Tunnels: Add tunnel` is reachable from the Hosts
1337/// tab and vice versa.
1338static ALL_JUMP_ACTIONS: &[JumpAction] = &[
1339    JumpAction {
1340        key: 'a',
1341        key_str: "a",
1342        label: "Hosts: Add host",
1343        aliases: &["new", "create"],
1344        target: JumpActionTarget::Hosts,
1345    },
1346    JumpAction {
1347        key: 'A',
1348        key_str: "A",
1349        label: "Hosts: Add pattern",
1350        aliases: &["new pattern", "wildcard"],
1351        target: JumpActionTarget::Hosts,
1352    },
1353    JumpAction {
1354        key: 'e',
1355        key_str: "e",
1356        label: "Hosts: Edit host",
1357        aliases: &["modify", "change"],
1358        target: JumpActionTarget::Hosts,
1359    },
1360    JumpAction {
1361        key: 'd',
1362        key_str: "d",
1363        label: "Hosts: Delete host",
1364        aliases: &["remove", "rm"],
1365        target: JumpActionTarget::Hosts,
1366    },
1367    JumpAction {
1368        key: 'c',
1369        key_str: "c",
1370        label: "Hosts: Clone host",
1371        aliases: &["duplicate", "copy"],
1372        target: JumpActionTarget::Hosts,
1373    },
1374    JumpAction {
1375        key: 'u',
1376        key_str: "u",
1377        label: "Hosts: Undo delete",
1378        aliases: &["restore"],
1379        target: JumpActionTarget::Hosts,
1380    },
1381    JumpAction {
1382        key: 't',
1383        key_str: "t",
1384        label: "Hosts: Tag host",
1385        aliases: &["label", "category"],
1386        target: JumpActionTarget::Hosts,
1387    },
1388    JumpAction {
1389        key: 'i',
1390        key_str: "i",
1391        label: "Hosts: Show all directives",
1392        aliases: &["raw", "config", "settings"],
1393        target: JumpActionTarget::Hosts,
1394    },
1395    JumpAction {
1396        key: 'y',
1397        key_str: "y",
1398        label: "Clipboard: Copy SSH command",
1399        aliases: &["yank"],
1400        target: JumpActionTarget::Hosts,
1401    },
1402    JumpAction {
1403        key: 'x',
1404        key_str: "x",
1405        label: "Clipboard: Copy config block",
1406        aliases: &["yank config"],
1407        target: JumpActionTarget::Hosts,
1408    },
1409    JumpAction {
1410        key: 'X',
1411        key_str: "X",
1412        label: "Hosts: Purge stale hosts",
1413        aliases: &["clean", "cleanup"],
1414        target: JumpActionTarget::Hosts,
1415    },
1416    JumpAction {
1417        key: 'F',
1418        key_str: "F",
1419        label: "Files: Browse remote files",
1420        aliases: &[
1421            "browse",
1422            "filesystem",
1423            "scp",
1424            "sftp",
1425            "transfer",
1426            "explorer",
1427            "open",
1428        ],
1429        target: JumpActionTarget::Hosts,
1430    },
1431    JumpAction {
1432        key: 'C',
1433        key_str: "C",
1434        label: "Containers: List containers",
1435        aliases: &["docker", "podman", "ps", "open"],
1436        target: JumpActionTarget::Hosts,
1437    },
1438    JumpAction {
1439        key: 'K',
1440        key_str: "K",
1441        label: "Keys: Manage SSH keys",
1442        aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
1443        target: JumpActionTarget::Hosts,
1444    },
1445    JumpAction {
1446        key: 'S',
1447        key_str: "S",
1448        label: "Providers: Manage cloud sync",
1449        aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
1450        target: JumpActionTarget::Hosts,
1451    },
1452    JumpAction {
1453        key: 'V',
1454        key_str: "V",
1455        label: "Vault: Sign certificate",
1456        aliases: &["hashicorp", "ssh cert", "vault ssh"],
1457        target: JumpActionTarget::Hosts,
1458    },
1459    JumpAction {
1460        key: 'I',
1461        key_str: "I",
1462        label: "Hosts: Import from known_hosts",
1463        aliases: &["known", "import"],
1464        target: JumpActionTarget::Hosts,
1465    },
1466    JumpAction {
1467        key: 'm',
1468        key_str: "m",
1469        label: "Settings: Switch theme",
1470        aliases: &["color", "appearance", "dark", "light"],
1471        target: JumpActionTarget::Hosts,
1472    },
1473    JumpAction {
1474        key: 'n',
1475        key_str: "n",
1476        label: "Help: What's new",
1477        aliases: &["changelog", "news", "release notes"],
1478        target: JumpActionTarget::Hosts,
1479    },
1480    JumpAction {
1481        key: 'r',
1482        key_str: "r",
1483        label: "Snippets: Run snippet",
1484        aliases: &["execute", "command"],
1485        target: JumpActionTarget::Hosts,
1486    },
1487    JumpAction {
1488        key: 'R',
1489        key_str: "R",
1490        label: "Snippets: Run on all visible",
1491        aliases: &["batch", "execute all"],
1492        target: JumpActionTarget::Hosts,
1493    },
1494    JumpAction {
1495        key: 'p',
1496        key_str: "p",
1497        label: "Hosts: Ping host",
1498        aliases: &["health", "check"],
1499        target: JumpActionTarget::Hosts,
1500    },
1501    JumpAction {
1502        key: 'P',
1503        key_str: "P",
1504        label: "Hosts: Ping all hosts",
1505        aliases: &["health all"],
1506        target: JumpActionTarget::Hosts,
1507    },
1508    JumpAction {
1509        key: '!',
1510        key_str: "!",
1511        label: "Hosts: Show down only",
1512        aliases: &["filter offline", "down only"],
1513        target: JumpActionTarget::Hosts,
1514    },
1515    // Tunnel-tab actions. Disambiguated by label so they coexist with
1516    // hosts-tab hotkey letters in the same list. Dispatch switches to
1517    // Tunnels top-page before synthesising the keypress.
1518    JumpAction {
1519        key: 'T',
1520        key_str: "T",
1521        label: "Tunnels: Manage tunnels",
1522        aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
1523        target: JumpActionTarget::Hosts,
1524    },
1525    JumpAction {
1526        key: 'a',
1527        key_str: "a",
1528        label: "Tunnels: Add tunnel",
1529        aliases: &["new tunnel", "create tunnel", "forward"],
1530        target: JumpActionTarget::Tunnels,
1531    },
1532    JumpAction {
1533        key: 'e',
1534        key_str: "e",
1535        label: "Tunnels: Edit tunnel",
1536        aliases: &["modify tunnel"],
1537        target: JumpActionTarget::Tunnels,
1538    },
1539    JumpAction {
1540        key: 'd',
1541        key_str: "d",
1542        label: "Tunnels: Delete tunnel",
1543        aliases: &["remove tunnel"],
1544        target: JumpActionTarget::Tunnels,
1545    },
1546    JumpAction {
1547        key: 's',
1548        key_str: "s",
1549        label: "Tunnels: Sort",
1550        aliases: &["order tunnels"],
1551        target: JumpActionTarget::Tunnels,
1552    },
1553    JumpAction {
1554        key: 'R',
1555        key_str: "R",
1556        label: "Containers: Refresh all hosts",
1557        aliases: &["reload containers", "fetch", "rescan"],
1558        target: JumpActionTarget::Containers,
1559    },
1560    JumpAction {
1561        key: 's',
1562        key_str: "s",
1563        label: "Containers: Cycle sort",
1564        aliases: &["order containers", "sort by host", "sort by name"],
1565        target: JumpActionTarget::Containers,
1566    },
1567    JumpAction {
1568        key: 'v',
1569        key_str: "v",
1570        label: "Containers: Toggle detail panel",
1571        aliases: &["show details", "hide details", "compact view"],
1572        target: JumpActionTarget::Containers,
1573    },
1574    // Keys tab. Mirror the footer + handler bindings on the Keys tab so
1575    // typing `:` followed by part of a verb (e.g. `push`, `sign`, `copy`)
1576    // surfaces the same actions the keyboard shortcuts already trigger.
1577    JumpAction {
1578        key: 'c',
1579        key_str: "c",
1580        label: "Keys: Copy public key",
1581        aliases: &["yank", "clipboard", "pubkey"],
1582        target: JumpActionTarget::Keys,
1583    },
1584    JumpAction {
1585        key: 'p',
1586        key_str: "p",
1587        label: "Keys: Push to host",
1588        aliases: &["install", "ssh-copy-id", "deploy", "upload"],
1589        target: JumpActionTarget::Keys,
1590    },
1591    JumpAction {
1592        key: 'V',
1593        key_str: "V",
1594        label: "Keys: Sign Vault SSH certificate",
1595        aliases: &["vault", "renew cert", "sign"],
1596        target: JumpActionTarget::Keys,
1597    },
1598];
1599
1600/// Cap on hits rendered per section. Broad queries (e.g. one character)
1601/// match thousands of candidates; capping keeps the jump bar legible without
1602/// virtualizing the render. The selected hit always falls within the cap
1603/// because results are sorted by score before truncation.
1604pub const PALETTE_PER_SECTION_CAP: usize = 32;
1605
1606/// Field-prefix parser: `user:eric` → (`Some(QueryScope::User)`, "eric").
1607/// Returns `(None, query)` for queries without a recognised scope.
1608pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
1609    if let Some((prefix, rest)) = query.split_once(':') {
1610        let scope = match prefix.trim() {
1611            "user" => Some(QueryScope::User),
1612            "host" => Some(QueryScope::Hostname),
1613            "proxy" => Some(QueryScope::ProxyJump),
1614            "vault" => Some(QueryScope::VaultSsh),
1615            "tag" => Some(QueryScope::Tag),
1616            _ => None,
1617        };
1618        if scope.is_some() {
1619            return (scope, rest.trim_start());
1620        }
1621    }
1622    (None, query)
1623}
1624
1625#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1626pub enum QueryScope {
1627    User,
1628    Hostname,
1629    ProxyJump,
1630    VaultSsh,
1631    Tag,
1632}
1633
1634/// Truncate a string to `max` characters, appending "..." if cut.
1635fn preview(s: &str, max: usize) -> String {
1636    let s = s.replace('\n', " ");
1637    let chars: Vec<char> = s.chars().collect();
1638    if chars.len() <= max {
1639        s
1640    } else {
1641        let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
1642        out.push_str("...");
1643        out
1644    }
1645}
1646
1647/// Restrict scoring to a single field when the user prefixes the query
1648/// with `user:` / `host:` / `proxy:` / `vault:` / `tag:`. Returns `None`
1649/// when no scope is set OR when the scope does not apply to the hit
1650/// (e.g. `vault:` on a snippet). caller falls back to the full set.
1651fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
1652    let scope = scope?;
1653    match (hit, scope) {
1654        (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
1655        (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
1656            Some(vec![&h.hostname])
1657        }
1658        (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
1659            Some(vec![&h.proxy_jump])
1660        }
1661        (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
1662        (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
1663        // Scoped queries do not match other kinds.
1664        _ => None,
1665    }
1666}
1667
1668/// Determine which field caused the host hit to match. The renderer uses
1669/// this to append a `via user`, `via proxy`, `vault: <role>` hint to the
1670/// row when the matched field is not part of the visible columns. Returns
1671/// `None` if the alias/hostname (already visible) matched.
1672pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
1673    if query.is_empty() {
1674        return None;
1675    }
1676    let q = query.to_lowercase();
1677    let alias_hit = host.alias.to_lowercase().contains(&q);
1678    let hostname_hit = host.hostname.to_lowercase().contains(&q);
1679    if alias_hit || hostname_hit {
1680        return None;
1681    }
1682    if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
1683        return Some(MatchSource::User);
1684    }
1685    if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
1686        return Some(MatchSource::ProxyJump);
1687    }
1688    if let Some(role) = &host.vault_ssh {
1689        if role.to_lowercase().contains(&q) {
1690            return Some(MatchSource::VaultSsh);
1691        }
1692    }
1693    if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
1694        return Some(MatchSource::IdentityFile);
1695    }
1696    None
1697}
1698
1699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1700pub enum MatchSource {
1701    User,
1702    ProxyJump,
1703    VaultSsh,
1704    IdentityFile,
1705}
1706
1707fn kind_rank(k: SourceKind) -> u8 {
1708    match k {
1709        SourceKind::Host => 0,
1710        SourceKind::Tunnel => 1,
1711        SourceKind::Container => 2,
1712        SourceKind::Snippet => 3,
1713        SourceKind::Action => 4,
1714    }
1715}
1716
1717/// Find `prior` in `hits` and return its index, or `fallback` if the prior
1718/// hit is gone (e.g. the typed query no longer matches it). Used by
1719/// `recompute_jump_hits` so mid-typing arrow navigation does not lose
1720/// the user's place.
1721fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
1722    if let Some(target) = prior {
1723        if let Some(idx) = hits.iter().position(|h| &h.identity() == target) {
1724            return idx;
1725        }
1726    }
1727    fallback.min(hits.len().saturating_sub(1))
1728}
1729
1730impl JumpAction {
1731    #[cfg(test)]
1732    pub fn all() -> &'static [JumpAction] {
1733        ALL_JUMP_ACTIONS
1734    }
1735
1736    /// The jump bar surfaces the same action set regardless of mode now.
1737    /// `mode` is preserved on the API so the dispatcher and test helpers
1738    /// can still pass through, but it no longer narrows the visible list.
1739    pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
1740        ALL_JUMP_ACTIONS
1741    }
1742}
1743
1744#[cfg(test)]
1745mod tests;