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