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