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