Skip to main content

purple_ssh/
app.rs

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