Skip to main content

purple_ssh/
app.rs

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