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