Skip to main content

purple_ssh/app/
jump.rs

1//! Unified jump bar types.
2//!
3//! Sources hosts, tunnels, containers, snippets and actions in one ranked
4//! list. Sections render in a fixed order. Empty sections are omitted.
5
6use std::path::PathBuf;
7
8use crate::fs_util::atomic_write;
9
10/// What kind of thing a jump hit represents. Drives the type-marker glyph
11/// rendered in the left column and the section grouping.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SourceKind {
15    Host,
16    Tunnel,
17    Container,
18    Snippet,
19    Action,
20}
21
22impl SourceKind {
23    pub fn section_label(self) -> &'static str {
24        match self {
25            Self::Host => "HOSTS",
26            Self::Tunnel => "TUNNELS",
27            Self::Container => "CONTAINERS",
28            Self::Snippet => "SNIPPETS",
29            Self::Action => "ACTIONS",
30        }
31    }
32
33    /// Fixed render order. Empty sections are skipped at render time but the
34    /// order itself never changes. Keeps muscle memory stable.
35    pub fn render_order() -> [Self; 5] {
36        [
37            Self::Host,
38            Self::Tunnel,
39            Self::Container,
40            Self::Snippet,
41            Self::Action,
42        ]
43    }
44}
45
46/// One row in the unified jump bar. Each variant carries enough state for the
47/// dispatch step to navigate the user to the matched item.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum JumpHit {
50    Action(JumpAction),
51    Host(HostHit),
52    Tunnel(TunnelHit),
53    Container(ContainerHit),
54    Snippet(SnippetHit),
55}
56
57impl JumpHit {
58    pub fn kind(&self) -> SourceKind {
59        match self {
60            Self::Action(_) => SourceKind::Action,
61            Self::Host(_) => SourceKind::Host,
62            Self::Tunnel(_) => SourceKind::Tunnel,
63            Self::Container(_) => SourceKind::Container,
64            Self::Snippet(_) => SourceKind::Snippet,
65        }
66    }
67
68    /// All searchable strings, including aliases. Score = max over haystacks.
69    /// Returns borrowed slices so the scoring loop is allocation-free per
70    /// hit. The single exception is the action hotkey which needs a tiny
71    /// owned buffer; we render it via `key_str` which is a `String` field
72    /// on `JumpAction`.
73    pub fn haystacks(&self) -> Vec<&str> {
74        match self {
75            Self::Action(a) => {
76                let mut v = Vec::with_capacity(2 + a.aliases.len());
77                v.push(a.label);
78                v.push(a.key_str);
79                for alias in a.aliases {
80                    v.push(*alias);
81                }
82                v
83            }
84            Self::Host(h) => {
85                let mut v = Vec::with_capacity(7 + h.tags.len());
86                v.push(h.alias.as_str());
87                v.push(h.hostname.as_str());
88                if let Some(p) = &h.provider {
89                    v.push(p.as_str());
90                }
91                for t in &h.tags {
92                    v.push(t.as_str());
93                }
94                if !h.user.is_empty() {
95                    v.push(h.user.as_str());
96                }
97                if !h.identity_file.is_empty() {
98                    v.push(h.identity_file.as_str());
99                }
100                if !h.proxy_jump.is_empty() {
101                    v.push(h.proxy_jump.as_str());
102                }
103                if let Some(role) = &h.vault_ssh {
104                    v.push(role.as_str());
105                }
106                v
107            }
108            Self::Tunnel(t) => vec![t.alias.as_str(), t.destination.as_str(), &t.bind_port_str],
109            Self::Container(c) => vec![
110                c.container_name.as_str(),
111                c.alias.as_str(),
112                c.container_id.as_str(),
113            ],
114            Self::Snippet(s) => vec![s.name.as_str(), s.command_preview.as_str()],
115        }
116    }
117
118    /// Stable identity used for MRU dedup.
119    pub fn identity(&self) -> RecentRef {
120        match self {
121            Self::Action(a) => RecentRef::new(SourceKind::Action, a.key.to_string()),
122            Self::Host(h) => RecentRef::new(SourceKind::Host, h.alias.clone()),
123            Self::Tunnel(t) => {
124                RecentRef::new(SourceKind::Tunnel, format!("{}:{}", t.alias, t.bind_port))
125            }
126            Self::Container(c) => RecentRef::new(
127                SourceKind::Container,
128                format!("{}/{}", c.alias, c.container_name),
129            ),
130            Self::Snippet(s) => RecentRef::new(SourceKind::Snippet, s.name.clone()),
131        }
132    }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct JumpAction {
137    pub key: char,
138    /// Same letter as `key` but as a `&'static str` so it can be used as a
139    /// haystack without allocating per scoring call. Stored once in the
140    /// static action table; verified by debug assertion in tests.
141    pub key_str: &'static str,
142    pub label: &'static str,
143    pub aliases: &'static [&'static str],
144    /// Which top-page handler executes this action. The dispatch path
145    /// switches `app.top_page` to this target before synthesising the
146    /// hotkey keypress, so `Tunnels: Add tunnel` works from the Hosts
147    /// tab and vice versa.
148    pub target: JumpActionTarget,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum JumpActionTarget {
153    Hosts,
154    Tunnels,
155    Containers,
156    Keys,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct HostHit {
161    pub alias: String,
162    pub hostname: String,
163    pub tags: Vec<String>,
164    pub provider: Option<String>,
165    pub user: String,
166    pub identity_file: String,
167    pub proxy_jump: String,
168    pub vault_ssh: Option<String>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct TunnelHit {
173    pub alias: String,
174    pub bind_port: u16,
175    /// Pre-rendered port number, kept around so `haystacks()` can return
176    /// borrowed slices instead of allocating a fresh `format!` per
177    /// keystroke.
178    pub bind_port_str: String,
179    pub destination: String,
180    pub active: bool,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct ContainerHit {
185    pub alias: String,
186    pub container_name: String,
187    pub container_id: String,
188    pub state: String,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct SnippetHit {
193    pub name: String,
194    pub command_preview: String,
195}
196
197/// Stable reference to a hit, used for the on-disk MRU log and for
198/// dispatching jumps.
199#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
200pub struct RecentRef {
201    pub kind: SourceKind,
202    pub key: String,
203}
204
205impl RecentRef {
206    pub fn new(kind: SourceKind, key: String) -> Self {
207        Self { kind, key }
208    }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
212pub struct RecentEntry {
213    #[serde(flatten)]
214    pub target: RecentRef,
215    pub last_used_unix: i64,
216}
217
218/// On-disk schema for `~/.purple/recents.json`. Versioned so future shape
219/// changes can rev without dropping user state.
220#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221pub struct RecentsFile {
222    pub version: u32,
223    pub entries: Vec<RecentEntry>,
224}
225
226impl Default for RecentsFile {
227    fn default() -> Self {
228        Self {
229            version: 1,
230            entries: Vec::new(),
231        }
232    }
233}
234
235const RECENTS_VERSION: u32 = 1;
236const RECENTS_CAP: usize = 50;
237
238/// Resolve the recents file path. Honors `purple_recents_path_override`
239/// for tests; otherwise lives at `~/.purple/recents.json`.
240pub fn recents_path() -> Option<PathBuf> {
241    if let Some(p) = recents_path_override() {
242        return Some(p);
243    }
244    let home = dirs::home_dir()?;
245    Some(home.join(".purple").join("recents.json"))
246}
247
248// Test-only override pattern. **Thread-local** so parallel `cargo test`
249// threads do not see each other's overrides. The previous `Mutex` shape
250// caused contamination: any test that triggered a record dispatch on
251// thread A would observe an override set by an unrelated test on thread B
252// and write into B's tempdir, breaking B's roundtrip assertions.
253#[cfg(test)]
254pub mod test_path {
255    use std::cell::RefCell;
256    use std::path::PathBuf;
257
258    thread_local! {
259        static OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
260    }
261
262    pub fn set(path: PathBuf) {
263        OVERRIDE.with(|cell| *cell.borrow_mut() = Some(path));
264    }
265
266    pub fn clear() {
267        OVERRIDE.with(|cell| *cell.borrow_mut() = None);
268    }
269
270    pub fn get() -> Option<PathBuf> {
271        OVERRIDE.with(|cell| cell.borrow().clone())
272    }
273}
274
275#[cfg(test)]
276fn recents_path_override() -> Option<PathBuf> {
277    test_path::get()
278}
279
280#[cfg(not(test))]
281fn recents_path_override() -> Option<PathBuf> {
282    None
283}
284
285pub fn load_recents() -> RecentsFile {
286    #[cfg(test)]
287    {
288        // Test builds only read recents when a tempdir override is set. See
289        // the matching guard in `save_recents` for the rationale.
290        if test_path::get().is_none() {
291            return RecentsFile::default();
292        }
293    }
294    let Some(path) = recents_path() else {
295        return RecentsFile::default();
296    };
297    let bytes = match std::fs::read(&path) {
298        Ok(b) => b,
299        Err(_) => return RecentsFile::default(),
300    };
301    serde_json::from_slice(&bytes).unwrap_or_default()
302}
303
304pub fn save_recents(file: &RecentsFile) -> std::io::Result<()> {
305    // In test builds, only persist when a test has explicitly set a
306    // tempdir override. This keeps tests that exercise the dispatch path
307    // (which calls `record_jump_hit`) from contaminating either the
308    // user's real `~/.purple/recents.json` or other tests' tempdirs via
309    // the shared override slot.
310    #[cfg(test)]
311    {
312        if test_path::get().is_none() {
313            return Ok(());
314        }
315    }
316    let Some(path) = recents_path() else {
317        return Ok(());
318    };
319    if let Some(parent) = path.parent() {
320        std::fs::create_dir_all(parent)?;
321    }
322    let bytes = serde_json::to_vec_pretty(file).map_err(std::io::Error::other)?;
323    atomic_write(&path, &bytes)
324}
325
326/// Rewrite host recents from `old_alias` to `new_alias`. Called from the
327/// host-form rename path so the jump bar's RECENT section keeps the host
328/// after a rename. When both aliases already have entries (defensive) the
329/// newer `last_used_unix` wins and the duplicate is dropped.
330///
331/// Returns `true` when the file changed.
332pub fn rename_host_recent(file: &mut RecentsFile, old_alias: &str, new_alias: &str) -> bool {
333    if old_alias == new_alias {
334        return false;
335    }
336    let old_idx = file
337        .entries
338        .iter()
339        .position(|e| e.target.kind == SourceKind::Host && e.target.key == old_alias);
340    let Some(old_idx) = old_idx else {
341        return false;
342    };
343    let new_idx = file
344        .entries
345        .iter()
346        .position(|e| e.target.kind == SourceKind::Host && e.target.key == new_alias);
347    if let Some(new_idx) = new_idx {
348        let drop_idx =
349            if file.entries[old_idx].last_used_unix >= file.entries[new_idx].last_used_unix {
350                new_idx
351            } else {
352                old_idx
353            };
354        let keep_idx = if drop_idx == new_idx {
355            old_idx
356        } else {
357            new_idx
358        };
359        file.entries[keep_idx].target.key = new_alias.to_string();
360        file.entries.remove(drop_idx);
361    } else {
362        file.entries[old_idx].target.key = new_alias.to_string();
363    }
364    file.version = RECENTS_VERSION;
365    true
366}
367
368/// Insert or move-to-front a recent ref. Caps the list at `RECENTS_CAP`.
369pub fn touch_recent(file: &mut RecentsFile, target: RecentRef) {
370    file.version = RECENTS_VERSION;
371    file.entries.retain(|e| e.target != target);
372    let now = current_unix_ts();
373    file.entries.insert(
374        0,
375        RecentEntry {
376            target,
377            last_used_unix: now,
378        },
379    );
380    if file.entries.len() > RECENTS_CAP {
381        file.entries.truncate(RECENTS_CAP);
382    }
383}
384
385fn current_unix_ts() -> i64 {
386    std::time::SystemTime::now()
387        .duration_since(std::time::UNIX_EPOCH)
388        .map(|d| d.as_secs() as i64)
389        .unwrap_or(0)
390}
391
392/// Which command set the jump bar displays. Determined by the screen that
393/// opened the jump bar so the action list matches what the underlying
394/// handler can dispatch.
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
396pub enum JumpMode {
397    #[default]
398    Hosts,
399    Tunnels,
400    Containers,
401    Keys,
402}
403
404/// On the empty-query state we show only the top-N actions to keep the
405/// first impression a short menu rather than a wall. The full list is
406/// one keystroke away. Lives in the data layer so `visible_hits()`,
407/// `empty_state_groups()` and the Down handler all agree on the bound.
408pub const JUMP_EMPTY_STATE_ACTIONS_CAP: usize = 6;
409
410/// On context-specific tabs (Tunnels, Containers) the empty-state bumps
411/// up to this many actions of the active tab's category to the front,
412/// before round-robining the remaining slots across other categories.
413/// Sized so half the cap surfaces tab actions and the other half stays
414/// reachable as a hub menu (cross-tab discovery).
415const EMPTY_STATE_TAB_BIAS: usize = 3;
416
417/// Display order for action categories on the empty state. The
418/// round-robin walks these buckets in this order, NOT in static-table
419/// order, so the first impression always shows the most-used categories
420/// regardless of how the static action list happens to be sorted.
421/// Categories not listed here fall through to a stable last-seen order.
422const CATEGORY_PRIORITY: &[&str] = &[
423    "Hosts",
424    "Tunnels",
425    "Containers",
426    "Files",
427    "Vault",
428    "Keys",
429    "Providers",
430    "Snippets",
431    "Clipboard",
432    "Settings",
433    "Help",
434];
435
436/// Minimum nucleo score for actions. Below this the action is dropped from
437/// results. Stops broad character-scatter matches on action labels.
438pub(crate) const PALETTE_ACTION_FLOOR: u32 = 30;
439
440/// Reorder actions so the first N show one per category, the next N
441/// show the second action of each category, etc. Preserves within-bucket
442/// order so muscle memory survives. Buckets are visited in
443/// `CATEGORY_PRIORITY` order. declarative, decoupled from static-table
444/// row order. so the empty-state top-N always leads with the most
445/// important categories. Categories not in the priority list fall to
446/// the end in stable encounter order.
447fn round_robin_actions_by_category(actions: impl Iterator<Item = JumpAction>) -> Vec<JumpHit> {
448    let mut buckets: Vec<(String, Vec<JumpAction>)> = Vec::new();
449    for action in actions {
450        let category = action
451            .label
452            .split_once(':')
453            .map(|(c, _)| c.trim().to_string())
454            .unwrap_or_else(|| "Other".to_string());
455        if let Some(slot) = buckets.iter_mut().find(|(c, _)| c == &category) {
456            slot.1.push(action);
457        } else {
458            buckets.push((category, vec![action]));
459        }
460    }
461    let priority_index = |cat: &str| -> usize {
462        CATEGORY_PRIORITY
463            .iter()
464            .position(|p| *p == cat)
465            .unwrap_or(usize::MAX)
466    };
467    buckets.sort_by_key(|(c, _)| priority_index(c));
468    let mut out: Vec<JumpHit> = Vec::new();
469    let mut depth = 0usize;
470    let max_depth = buckets.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
471    while depth < max_depth {
472        for (_, bucket) in &buckets {
473            if let Some(action) = bucket.get(depth) {
474                out.push(JumpHit::Action(*action));
475            }
476        }
477        depth += 1;
478    }
479    out
480}
481
482/// Like `round_robin_actions_by_category` but pulls up to `bump` actions
483/// whose dispatch `target` matches `preferred` to the front before
484/// round-robining the rest. Used by the empty-state on context-specific
485/// tabs (Tunnels, Containers) so the user sees actions that operate on
486/// the active tab, not just actions whose label happens to start with
487/// the same prefix. Filtering by `target` (dispatch destination) instead
488/// of label category keeps `Containers: List containers` (target=Hosts,
489/// opens the legacy per-host overlay) out of the bias on the Containers
490/// tab, where it would otherwise crowd out the genuinely tab-relevant
491/// `Refresh / Cycle sort / Toggle detail panel` actions.
492fn round_robin_actions_with_bias(
493    actions: impl Iterator<Item = JumpAction>,
494    preferred: JumpActionTarget,
495    bump: usize,
496) -> Vec<JumpHit> {
497    let collected: Vec<JumpAction> = actions.collect();
498    let biased: Vec<JumpAction> = collected
499        .iter()
500        .filter(|a| a.target == preferred)
501        .take(bump)
502        .copied()
503        .collect();
504    let biased_keys: std::collections::HashSet<char> = biased.iter().map(|a| a.key).collect();
505    let rest: Vec<JumpAction> = collected
506        .into_iter()
507        .filter(|a| !(biased_keys.contains(&a.key) && a.target == preferred))
508        .collect();
509    let mut out: Vec<JumpHit> = biased.into_iter().map(JumpHit::Action).collect();
510    out.extend(round_robin_actions_by_category(rest.into_iter()));
511    out
512}
513
514#[derive(Debug, Default)]
515pub struct JumpState {
516    pub(in crate::app) query: String,
517    pub(in crate::app) selected: usize,
518    pub(in crate::app) mode: JumpMode,
519    /// Computed result list, recomputed on every query change. Empty until
520    /// `App::recompute_jump_hits` runs.
521    pub(in crate::app) hits: Vec<JumpHit>,
522    /// MRU snapshot loaded on jump bar open, used by the empty-query state.
523    pub(in crate::app) recents: Vec<JumpHit>,
524    /// True once the user has navigated (Down/Up/Tab) at least once. The
525    /// renderer keeps the selection invisible on the empty state until
526    /// this flips, so the eye stays on the input field on first open.
527    /// Also makes the FIRST Down keystroke land on row 0 instead of
528    /// skipping to row 1.
529    pub(in crate::app) cursor_revealed: bool,
530    /// Reused matcher with growable scratch buffers. Populated lazily on
531    /// the first scoring pass and kept across keystrokes so nucleo's
532    /// internal vectors do not reallocate every recompute.
533    pub(in crate::app) matcher: Option<nucleo_matcher::Matcher>,
534}
535
536// Manual `Clone` because `nucleo_matcher::Matcher` is not `Clone`. State
537// clones (e.g. in tests) drop the cached matcher and let the next
538// recompute build a fresh one. correct behavior, just slightly slower
539// for the next keystroke after a clone.
540impl Clone for JumpState {
541    fn clone(&self) -> Self {
542        Self {
543            query: self.query.clone(),
544            selected: self.selected,
545            mode: self.mode,
546            hits: self.hits.clone(),
547            recents: self.recents.clone(),
548            cursor_revealed: self.cursor_revealed,
549            matcher: None,
550        }
551    }
552}
553
554impl JumpState {
555    pub fn for_mode(mode: JumpMode) -> Self {
556        Self {
557            mode,
558            ..Self::default()
559        }
560    }
561
562    pub fn query(&self) -> &str {
563        &self.query
564    }
565
566    pub fn selected(&self) -> usize {
567        self.selected
568    }
569
570    pub fn mode(&self) -> JumpMode {
571        self.mode
572    }
573
574    pub fn cursor_revealed(&self) -> bool {
575        self.cursor_revealed
576    }
577
578    pub fn hits(&self) -> &[JumpHit] {
579        &self.hits
580    }
581
582    pub fn recents(&self) -> &[JumpHit] {
583        &self.recents
584    }
585
586    pub fn set_selected(&mut self, n: usize) {
587        self.selected = n;
588    }
589
590    pub fn set_hits(&mut self, hits: Vec<JumpHit>) {
591        self.hits = hits;
592    }
593
594    pub fn set_recents(&mut self, recents: Vec<JumpHit>) {
595        self.recents = recents;
596    }
597
598    /// Down arrow: on first navigation reveal the cursor on row 0;
599    /// thereafter advance by one, capped at the last visible row.
600    pub fn move_down(&mut self) {
601        let count = self.visible_hits().len();
602        if count == 0 {
603            return;
604        }
605        if !self.cursor_revealed {
606            self.cursor_revealed = true;
607            self.selected = 0;
608        } else {
609            self.selected = (self.selected + 1).min(count - 1);
610        }
611    }
612
613    /// Up arrow: on first navigation reveal the cursor on row 0;
614    /// thereafter step back saturating at row 0.
615    pub fn move_up(&mut self) {
616        if !self.cursor_revealed {
617            self.cursor_revealed = true;
618            self.selected = 0;
619        } else {
620            self.selected = self.selected.saturating_sub(1);
621        }
622    }
623
624    pub fn reveal_cursor(&mut self) {
625        self.cursor_revealed = true;
626    }
627
628    /// Backspace cleared the query: re-hide the selection cue and re-park
629    /// the cursor on row 0 so the eye lands back on the input field.
630    pub fn reset_after_clear_query(&mut self) {
631        self.cursor_revealed = false;
632        self.selected = 0;
633    }
634
635    pub fn push_query(&mut self, c: char) {
636        if self.query.len() < 64 {
637            self.query.push(c);
638        }
639        // Selection is handled by `App::recompute_jump_hits` which
640        // tries to keep the previously-selected hit's identity. We do
641        // NOT reset to 0 here because that would defeat mid-typing
642        // navigation: typing a char must not jump the cursor.
643    }
644
645    pub fn pop_query(&mut self) {
646        self.query.pop();
647    }
648
649    /// Return the hit list to render. With an empty query this is the
650    /// composed empty-state view (recents + the round-robin top-N
651    /// actions); otherwise it is the live computed `hits`. The cap on
652    /// the empty state is applied HERE (data layer) so the Down/Up
653    /// handlers, `visible_hits().len()`, and the renderer all agree on
654    /// the same bound. without this, scrolling past the rendered cap
655    /// would silently advance `selected` into invisible rows and the
656    /// highlight would appear to jump back to row 0.
657    pub fn visible_hits(&self) -> Vec<JumpHit> {
658        if self.query.is_empty() {
659            let mut out: Vec<JumpHit> = self.recents.clone();
660            out.extend(self.empty_state_actions());
661            out
662        } else {
663            // Return hits in the same fixed-section render order the overlay
664            // lays them out, preserving the score order within each section.
665            // Navigation and dispatch index this list while the renderer
666            // groups by `SourceKind`; the two must agree or the highlighted
667            // row and the executed hit drift apart.
668            let mut out: Vec<JumpHit> = Vec::with_capacity(self.hits.len());
669            for kind in SourceKind::render_order() {
670                out.extend(self.hits.iter().filter(|h| h.kind() == kind).cloned());
671            }
672            out
673        }
674    }
675
676    /// Action set for the empty-state, after the `recent_keys` filter
677    /// is applied. Shared by `empty_state_actions` (which adds bias and
678    /// caps) and `empty_state_actions_total` (which just counts).
679    /// Centralising the filter predicate guarantees the rendered
680    /// "Actions  N of M" header stays in sync with the rendered list
681    /// across future edits.
682    fn filtered_actions_for_empty_state(&self) -> Vec<JumpAction> {
683        let recent_keys: std::collections::HashSet<RecentRef> =
684            self.recents.iter().map(|h| h.identity()).collect();
685        JumpAction::for_mode(self.mode)
686            .iter()
687            .filter(|a| {
688                let id = RecentRef::new(SourceKind::Action, a.key.to_string());
689                !recent_keys.contains(&id)
690            })
691            .copied()
692            .collect()
693    }
694
695    /// Top-N actions for the empty-state, after `recent_keys` filtering
696    /// and the optional tab-bias. Single source of truth for both the
697    /// renderer (`empty_state_groups`) and the navigation handler
698    /// (`visible_hits`); without it the two would drift on the bias and
699    /// the cursor would land on different rows than the user sees.
700    fn empty_state_actions(&self) -> Vec<JumpHit> {
701        let filtered = self.filtered_actions_for_empty_state();
702        let preferred_target = match self.mode {
703            JumpMode::Hosts => None,
704            JumpMode::Tunnels => Some(JumpActionTarget::Tunnels),
705            JumpMode::Containers => Some(JumpActionTarget::Containers),
706            JumpMode::Keys => Some(JumpActionTarget::Keys),
707        };
708        let actions = match preferred_target {
709            Some(t) => round_robin_actions_with_bias(filtered.into_iter(), t, EMPTY_STATE_TAB_BIAS),
710            None => round_robin_actions_by_category(filtered.into_iter()),
711        };
712        actions
713            .into_iter()
714            .take(JUMP_EMPTY_STATE_ACTIONS_CAP)
715            .collect()
716    }
717
718    /// Number of actions available for the empty-state ACTIONS section
719    /// BEFORE the cap. Used by the renderer to render `Actions  6 of 29`
720    /// when the cap is applied.
721    pub fn empty_state_actions_total(&self) -> usize {
722        self.filtered_actions_for_empty_state().len()
723    }
724
725    /// Group `visible_hits()` for the query view: by `SourceKind` in render
726    /// order. Empty sections are omitted. Only meaningful when a query is
727    /// active; the empty-state view uses `empty_state_groups` instead.
728    pub fn grouped_hits(&self) -> Vec<(SourceKind, Vec<JumpHit>)> {
729        let visible = self.visible_hits();
730        let mut out = Vec::with_capacity(SourceKind::render_order().len());
731        for kind in SourceKind::render_order() {
732            let group: Vec<JumpHit> = visible
733                .iter()
734                .filter(|h| h.kind() == kind)
735                .cloned()
736                .collect();
737            if !group.is_empty() {
738                out.push((kind, group));
739            }
740        }
741        out
742    }
743
744    /// Empty-state grouping: a single `RECENT` group (everything that came
745    /// from the MRU log, of any kind) followed by an `ACTIONS` group.
746    /// Returns `(label, hits)` rather than `(kind, hits)` so the renderer
747    /// can distinguish "RECENT" from a per-kind label.
748    pub fn empty_state_groups(&self) -> Vec<(&'static str, Vec<JumpHit>)> {
749        let mut out: Vec<(&'static str, Vec<JumpHit>)> = Vec::new();
750        if !self.recents.is_empty() {
751            out.push(("RECENT", self.recents.clone()));
752        }
753        // Single source of truth shared with `visible_hits` so the
754        // navigation cursor and the rendered list cannot drift.
755        let actions = self.empty_state_actions();
756        if !actions.is_empty() {
757            out.push(("ACTIONS", actions));
758        }
759        out
760    }
761
762    /// Map `selected` index (into `visible_hits()`) to a `SourceKind` so the
763    /// renderer knows which section header is currently active.
764    pub fn selected_section(&self) -> Option<SourceKind> {
765        self.visible_hits().get(self.selected).map(|h| h.kind())
766    }
767
768    /// Return actions whose label substring-matches the current query.
769    /// Test-only shim for tests that predate the unified jump bar.
770    /// Production code iterates `visible_hits()` instead.
771    #[cfg(test)]
772    pub fn filtered_commands(&self) -> Vec<JumpAction> {
773        let all = JumpAction::for_mode(self.mode);
774        if self.query.is_empty() {
775            return all.to_vec();
776        }
777        let q = self.query.to_lowercase();
778        all.iter()
779            .filter(|cmd| {
780                cmd.label.to_lowercase().contains(&q)
781                    || cmd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
782            })
783            .copied()
784            .collect()
785    }
786
787    /// Move selection to the first hit in the next non-empty section. Wraps.
788    pub fn jump_next_section(&mut self) {
789        let visible = self.visible_hits();
790        if visible.is_empty() {
791            return;
792        }
793        if self.query.is_empty() {
794            // Empty-state has up to two groups: RECENT (length =
795            // recents.len()) and ACTIONS (the rest). Tab toggles between
796            // their first rows. Skip the toggle if there is no second
797            // group to jump to (e.g. no recents, or no actions after
798            // recents). The two `if` branches inside this block both fire
799            // in real cases: from RECENT row n we jump to actions; from
800            // an action row we wrap back to the first recent.
801            let n_recent = self.recents.len();
802            if n_recent == 0 || n_recent >= visible.len() {
803                return;
804            }
805            if self.selected < n_recent {
806                self.selected = n_recent; // RECENT -> ACTIONS
807            } else {
808                self.selected = 0; // ACTIONS -> first RECENT
809            }
810            return;
811        }
812        let groups = self.grouped_hits();
813        if groups.len() < 2 {
814            return;
815        }
816        let cur_kind = match self.selected_section() {
817            Some(k) => k,
818            None => {
819                self.selected = 0;
820                return;
821            }
822        };
823        let cur_idx = groups.iter().position(|(k, _)| *k == cur_kind).unwrap_or(0);
824        let next_idx = (cur_idx + 1) % groups.len();
825        let next_kind = groups[next_idx].0;
826        if let Some(pos) = visible.iter().position(|h| h.kind() == next_kind) {
827            self.selected = pos;
828        }
829    }
830}
831
832#[cfg(test)]
833pub mod tests {
834    use super::*;
835
836    // `test_path` is thread-local, so each test thread gets an isolated
837    // recents file with no shared state. No process-wide lock is needed.
838    fn with_temp<F: FnOnce(&std::path::Path)>(f: F) {
839        let dir = tempfile::tempdir().unwrap();
840        let path = dir.path().join("recents.json");
841        test_path::set(path.clone());
842        f(&path);
843        test_path::clear();
844    }
845
846    #[test]
847    fn visible_hits_matches_grouped_render_order_with_active_query() {
848        // Regression for the jump-bar mis-dispatch: navigation (`move_down`)
849        // and dispatch index `visible_hits()`, while the renderer lays rows
850        // out in `grouped_hits()` order. When a query matches across sections
851        // the score-sorted flat list interleaves kinds differently from the
852        // fixed render order, so the highlighted row and the executed hit
853        // point at different items. Pin that the two orders agree.
854        let action = JumpAction::all()[0];
855        let host = HostHit {
856            alias: "proxy-vm".into(),
857            hostname: "proxy-vm.example.com".into(),
858            tags: Vec::new(),
859            provider: None,
860            user: String::new(),
861            identity_file: String::new(),
862            proxy_jump: String::new(),
863            vault_ssh: None,
864        };
865        // Score order puts the action first (a strong action match outranks a
866        // fuzzy host match). The renderer regroups HOSTS before ACTIONS.
867        let state = JumpState {
868            query: "prov".into(),
869            hits: vec![JumpHit::Action(action), JumpHit::Host(host)],
870            ..Default::default()
871        };
872
873        let visible = state.visible_hits();
874        let flattened: Vec<JumpHit> = state
875            .grouped_hits()
876            .into_iter()
877            .flat_map(|(_, hits)| hits)
878            .collect();
879        assert_eq!(
880            visible, flattened,
881            "visible_hits() must equal the flattened grouped order so the \
882             highlighted row and the dispatched hit reference the same item"
883        );
884        // Row 0 is what the selection cue lands on first; with HOSTS rendered
885        // before ACTIONS it must be the host, matching what the user sees.
886        assert!(
887            matches!(visible[0], JumpHit::Host(_)),
888            "first visible row must follow render order (HOSTS first)"
889        );
890    }
891
892    #[test]
893    fn section_labels_are_uppercase() {
894        for k in SourceKind::render_order() {
895            let label = k.section_label();
896            assert_eq!(label, label.to_uppercase(), "{:?} not uppercase", k);
897        }
898    }
899
900    #[test]
901    fn render_order_starts_with_hosts() {
902        assert_eq!(SourceKind::render_order()[0], SourceKind::Host);
903        assert_eq!(SourceKind::render_order()[4], SourceKind::Action);
904    }
905
906    #[test]
907    fn touch_moves_existing_to_front_and_caps() {
908        let mut f = RecentsFile::default();
909        for i in 0..(RECENTS_CAP + 5) {
910            touch_recent(&mut f, RecentRef::new(SourceKind::Host, format!("h{i}")));
911        }
912        assert_eq!(f.entries.len(), RECENTS_CAP);
913        // Re-touching an existing ref moves it to the front.
914        let target = RecentRef::new(SourceKind::Host, format!("h{}", RECENTS_CAP + 2));
915        touch_recent(&mut f, target.clone());
916        assert_eq!(f.entries[0].target, target);
917        assert_eq!(f.entries.len(), RECENTS_CAP);
918    }
919
920    #[test]
921    fn save_then_load_roundtrip() {
922        with_temp(|_path| {
923            let mut f = RecentsFile::default();
924            touch_recent(&mut f, RecentRef::new(SourceKind::Action, "F".into()));
925            touch_recent(&mut f, RecentRef::new(SourceKind::Host, "web-01".into()));
926            save_recents(&f).expect("save");
927            let loaded = load_recents();
928            assert_eq!(loaded.version, RECENTS_VERSION);
929            assert_eq!(loaded.entries.len(), 2);
930            assert_eq!(loaded.entries[0].target.key, "web-01");
931            assert_eq!(loaded.entries[1].target.key, "F");
932        });
933    }
934
935    #[test]
936    fn missing_file_loads_empty() {
937        with_temp(|_path| {
938            let loaded = load_recents();
939            assert!(loaded.entries.is_empty());
940        });
941    }
942
943    #[test]
944    fn corrupt_file_loads_empty() {
945        with_temp(|path| {
946            std::fs::write(path, b"not json").unwrap();
947            let loaded = load_recents();
948            assert!(loaded.entries.is_empty());
949        });
950    }
951
952    fn host_entry(alias: &str, ts: i64) -> RecentEntry {
953        RecentEntry {
954            target: RecentRef::new(SourceKind::Host, alias.to_string()),
955            last_used_unix: ts,
956        }
957    }
958
959    #[test]
960    fn rename_host_recent_rewrites_key() {
961        let mut file = RecentsFile::default();
962        file.entries.push(host_entry("web-old", 100));
963        file.entries.push(RecentEntry {
964            target: RecentRef::new(SourceKind::Tunnel, "web-old:5432".to_string()),
965            last_used_unix: 90,
966        });
967
968        assert!(rename_host_recent(&mut file, "web-old", "web-new"));
969        assert_eq!(file.entries[0].target.kind, SourceKind::Host);
970        assert_eq!(file.entries[0].target.key, "web-new");
971        // Non-host entries with a coincidental key prefix are untouched.
972        assert_eq!(file.entries[1].target.kind, SourceKind::Tunnel);
973        assert_eq!(file.entries[1].target.key, "web-old:5432");
974    }
975
976    #[test]
977    fn rename_host_recent_dedups_on_collision_keeping_most_recent() {
978        let mut file = RecentsFile::default();
979        // Old entry is more recent. After rename the newer timestamp must
980        // survive and the older duplicate must be dropped.
981        file.entries.push(host_entry("a", 200));
982        file.entries.push(host_entry("b", 100));
983
984        assert!(rename_host_recent(&mut file, "a", "b"));
985        assert_eq!(file.entries.len(), 1);
986        assert_eq!(file.entries[0].target.key, "b");
987        assert_eq!(file.entries[0].last_used_unix, 200);
988    }
989
990    #[test]
991    fn rename_host_recent_dedups_when_new_key_is_newer() {
992        let mut file = RecentsFile::default();
993        file.entries.push(host_entry("a", 100));
994        file.entries.push(host_entry("b", 200));
995
996        assert!(rename_host_recent(&mut file, "a", "b"));
997        assert_eq!(file.entries.len(), 1);
998        assert_eq!(file.entries[0].target.key, "b");
999        assert_eq!(file.entries[0].last_used_unix, 200);
1000    }
1001
1002    #[test]
1003    fn rename_host_recent_noop_when_same() {
1004        let mut file = RecentsFile::default();
1005        file.entries.push(host_entry("a", 10));
1006        assert!(!rename_host_recent(&mut file, "a", "a"));
1007        assert_eq!(file.entries.len(), 1);
1008    }
1009
1010    #[test]
1011    fn rename_host_recent_noop_when_absent() {
1012        let mut file = RecentsFile::default();
1013        assert!(!rename_host_recent(&mut file, "ghost", "phantom"));
1014        assert!(file.entries.is_empty());
1015    }
1016}