Skip to main content

purple_ssh/
app.rs

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