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