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