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    Snippets,
348    Keys,
349}
350
351/// On the empty-query state we show only the top-N actions to keep the
352/// first impression a short menu rather than a wall. The full list is
353/// one keystroke away. Lives in the data layer so `visible_hits()`,
354/// `empty_state_groups()` and the Down handler all agree on the bound.
355pub const JUMP_EMPTY_STATE_ACTIONS_CAP: usize = 6;
356
357/// On context-specific tabs (Tunnels, Containers) the empty-state bumps
358/// up to this many actions of the active tab's category to the front,
359/// before round-robining the remaining slots across other categories.
360/// Sized so half the cap surfaces tab actions and the other half stays
361/// reachable as a hub menu (cross-tab discovery).
362const EMPTY_STATE_TAB_BIAS: usize = 3;
363
364/// Display order for action categories on the empty state. The
365/// round-robin walks these buckets in this order, NOT in static-table
366/// order, so the first impression always shows the most-used categories
367/// regardless of how the static action list happens to be sorted.
368/// Categories not listed here fall through to a stable last-seen order.
369const CATEGORY_PRIORITY: &[&str] = &[
370    "Hosts",
371    "Tunnels",
372    "Containers",
373    "Files",
374    "Vault",
375    "Keys",
376    "Providers",
377    "Snippets",
378    "Clipboard",
379    "Settings",
380    "Help",
381];
382
383/// Minimum nucleo score for actions. Below this the action is dropped from
384/// results. Stops broad character-scatter matches on action labels.
385pub(crate) const PALETTE_ACTION_FLOOR: u32 = 30;
386
387/// Reorder actions so the first N show one per category, the next N
388/// show the second action of each category, etc. Preserves within-bucket
389/// order so muscle memory survives. Buckets are visited in
390/// `CATEGORY_PRIORITY` order. declarative, decoupled from static-table
391/// row order. so the empty-state top-N always leads with the most
392/// important categories. Categories not in the priority list fall to
393/// the end in stable encounter order.
394fn round_robin_actions_by_category(actions: impl Iterator<Item = JumpAction>) -> Vec<JumpHit> {
395    let mut buckets: Vec<(String, Vec<JumpAction>)> = Vec::new();
396    for action in actions {
397        let category = action
398            .label
399            .split_once(':')
400            .map(|(c, _)| c.trim().to_string())
401            .unwrap_or_else(|| "Other".to_string());
402        if let Some(slot) = buckets.iter_mut().find(|(c, _)| c == &category) {
403            slot.1.push(action);
404        } else {
405            buckets.push((category, vec![action]));
406        }
407    }
408    let priority_index = |cat: &str| -> usize {
409        CATEGORY_PRIORITY
410            .iter()
411            .position(|p| *p == cat)
412            .unwrap_or(usize::MAX)
413    };
414    buckets.sort_by_key(|(c, _)| priority_index(c));
415    let mut out: Vec<JumpHit> = Vec::new();
416    let mut depth = 0usize;
417    let max_depth = buckets.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
418    while depth < max_depth {
419        for (_, bucket) in &buckets {
420            if let Some(action) = bucket.get(depth) {
421                out.push(JumpHit::Action(*action));
422            }
423        }
424        depth += 1;
425    }
426    out
427}
428
429/// Like `round_robin_actions_by_category` but pulls up to `bump` actions
430/// whose dispatch `target` matches `preferred` to the front before
431/// round-robining the rest. Used by the empty-state on context-specific
432/// tabs (Tunnels, Containers) so the user sees actions that operate on
433/// the active tab, not just actions whose label happens to start with
434/// the same prefix. Filtering by `target` (dispatch destination) instead
435/// of label category keeps `Containers: List containers` (target=Hosts,
436/// opens the legacy per-host overlay) out of the bias on the Containers
437/// tab, where it would otherwise crowd out the genuinely tab-relevant
438/// `Refresh / Cycle sort / Toggle detail panel` actions.
439fn round_robin_actions_with_bias(
440    actions: impl Iterator<Item = JumpAction>,
441    preferred: JumpActionTarget,
442    bump: usize,
443) -> Vec<JumpHit> {
444    let collected: Vec<JumpAction> = actions.collect();
445    let biased: Vec<JumpAction> = collected
446        .iter()
447        .filter(|a| a.target == preferred)
448        .take(bump)
449        .copied()
450        .collect();
451    let biased_keys: std::collections::HashSet<char> = biased.iter().map(|a| a.key).collect();
452    let rest: Vec<JumpAction> = collected
453        .into_iter()
454        .filter(|a| !(biased_keys.contains(&a.key) && a.target == preferred))
455        .collect();
456    let mut out: Vec<JumpHit> = biased.into_iter().map(JumpHit::Action).collect();
457    out.extend(round_robin_actions_by_category(rest.into_iter()));
458    out
459}
460
461/// Like `round_robin_actions_with_bias` but pulls up to `bump` actions whose
462/// LABEL category matches `category` to the front. Used by the Snippets tab,
463/// whose run-actions dispatch to the host list (`target == Hosts`) yet belong to
464/// the "Snippets" category, so the target-based bias would miss them.
465fn round_robin_actions_with_category_bias(
466    actions: impl Iterator<Item = JumpAction>,
467    category: &str,
468    bump: usize,
469) -> Vec<JumpHit> {
470    let collected: Vec<JumpAction> = actions.collect();
471    let cat_of = |a: &JumpAction| a.label.split_once(':').map(|(c, _)| c.trim()).unwrap_or("");
472    let biased: Vec<JumpAction> = collected
473        .iter()
474        .filter(|a| cat_of(a) == category)
475        .take(bump)
476        .copied()
477        .collect();
478    let biased_keys: std::collections::HashSet<char> = biased.iter().map(|a| a.key).collect();
479    let rest: Vec<JumpAction> = collected
480        .into_iter()
481        .filter(|a| !(biased_keys.contains(&a.key) && cat_of(a) == category))
482        .collect();
483    let mut out: Vec<JumpHit> = biased.into_iter().map(JumpHit::Action).collect();
484    out.extend(round_robin_actions_by_category(rest.into_iter()));
485    out
486}
487
488#[derive(Debug, Default)]
489pub struct JumpState {
490    pub(in crate::app) query: String,
491    pub(in crate::app) selected: usize,
492    pub(in crate::app) mode: JumpMode,
493    /// Computed result list, recomputed on every query change. Empty until
494    /// `App::recompute_jump_hits` runs.
495    pub(in crate::app) hits: Vec<JumpHit>,
496    /// MRU snapshot loaded on jump bar open, used by the empty-query state.
497    pub(in crate::app) recents: Vec<JumpHit>,
498    /// True once the user has navigated (Down/Up/Tab) at least once. The
499    /// renderer keeps the selection invisible on the empty state until
500    /// this flips, so the eye stays on the input field on first open.
501    /// Also makes the FIRST Down keystroke land on row 0 instead of
502    /// skipping to row 1.
503    pub(in crate::app) cursor_revealed: bool,
504    /// Reused matcher with growable scratch buffers. Populated lazily on
505    /// the first scoring pass and kept across keystrokes so nucleo's
506    /// internal vectors do not reallocate every recompute.
507    pub(in crate::app) matcher: Option<nucleo_matcher::Matcher>,
508}
509
510// Manual `Clone` because `nucleo_matcher::Matcher` is not `Clone`. State
511// clones (e.g. in tests) drop the cached matcher and let the next
512// recompute build a fresh one. correct behavior, just slightly slower
513// for the next keystroke after a clone.
514impl Clone for JumpState {
515    fn clone(&self) -> Self {
516        Self {
517            query: self.query.clone(),
518            selected: self.selected,
519            mode: self.mode,
520            hits: self.hits.clone(),
521            recents: self.recents.clone(),
522            cursor_revealed: self.cursor_revealed,
523            matcher: None,
524        }
525    }
526}
527
528impl JumpState {
529    pub fn for_mode(mode: JumpMode) -> Self {
530        Self {
531            mode,
532            ..Self::default()
533        }
534    }
535
536    pub fn query(&self) -> &str {
537        &self.query
538    }
539
540    pub fn selected(&self) -> usize {
541        self.selected
542    }
543
544    pub fn mode(&self) -> JumpMode {
545        self.mode
546    }
547
548    pub fn cursor_revealed(&self) -> bool {
549        self.cursor_revealed
550    }
551
552    pub fn hits(&self) -> &[JumpHit] {
553        &self.hits
554    }
555
556    pub fn recents(&self) -> &[JumpHit] {
557        &self.recents
558    }
559
560    pub fn set_selected(&mut self, n: usize) {
561        self.selected = n;
562    }
563
564    pub fn set_hits(&mut self, hits: Vec<JumpHit>) {
565        self.hits = hits;
566    }
567
568    pub fn set_recents(&mut self, recents: Vec<JumpHit>) {
569        self.recents = recents;
570    }
571
572    /// Down arrow: on first navigation reveal the cursor on row 0;
573    /// thereafter advance by one, capped at the last visible row.
574    pub fn move_down(&mut self) {
575        let count = self.visible_hits().len();
576        if count == 0 {
577            return;
578        }
579        if !self.cursor_revealed {
580            self.cursor_revealed = true;
581            self.selected = 0;
582        } else {
583            self.selected = (self.selected + 1).min(count - 1);
584        }
585    }
586
587    /// Up arrow: on first navigation reveal the cursor on row 0;
588    /// thereafter step back saturating at row 0.
589    pub fn move_up(&mut self) {
590        if !self.cursor_revealed {
591            self.cursor_revealed = true;
592            self.selected = 0;
593        } else {
594            self.selected = self.selected.saturating_sub(1);
595        }
596    }
597
598    pub fn reveal_cursor(&mut self) {
599        self.cursor_revealed = true;
600    }
601
602    /// Backspace cleared the query: re-hide the selection cue and re-park
603    /// the cursor on row 0 so the eye lands back on the input field.
604    pub fn reset_after_clear_query(&mut self) {
605        self.cursor_revealed = false;
606        self.selected = 0;
607    }
608
609    pub fn push_query(&mut self, c: char) {
610        if self.query.len() < 64 {
611            self.query.push(c);
612        }
613        // Selection is handled by `App::recompute_jump_hits` which
614        // tries to keep the previously-selected hit's identity. We do
615        // NOT reset to 0 here because that would defeat mid-typing
616        // navigation: typing a char must not jump the cursor.
617    }
618
619    pub fn pop_query(&mut self) {
620        self.query.pop();
621    }
622
623    /// Return the hit list to render. With an empty query this is the
624    /// composed empty-state view (recents + the round-robin top-N
625    /// actions); otherwise it is the live computed `hits`. The cap on
626    /// the empty state is applied HERE (data layer) so the Down/Up
627    /// handlers, `visible_hits().len()`, and the renderer all agree on
628    /// the same bound. without this, scrolling past the rendered cap
629    /// would silently advance `selected` into invisible rows and the
630    /// highlight would appear to jump back to row 0.
631    pub fn visible_hits(&self) -> Vec<JumpHit> {
632        if self.query.is_empty() {
633            let mut out: Vec<JumpHit> = self.recents.clone();
634            out.extend(self.empty_state_actions());
635            out
636        } else {
637            // Return hits in the same fixed-section render order the overlay
638            // lays them out, preserving the score order within each section.
639            // Navigation and dispatch index this list while the renderer
640            // groups by `SourceKind`; the two must agree or the highlighted
641            // row and the executed hit drift apart.
642            let mut out: Vec<JumpHit> = Vec::with_capacity(self.hits.len());
643            for kind in SourceKind::render_order() {
644                out.extend(self.hits.iter().filter(|h| h.kind() == kind).cloned());
645            }
646            out
647        }
648    }
649
650    /// Action set for the empty-state, after the `recent_keys` filter
651    /// is applied. Shared by `empty_state_actions` (which adds bias and
652    /// caps) and `empty_state_actions_total` (which just counts).
653    /// Centralising the filter predicate guarantees the rendered
654    /// "Actions  N of M" header stays in sync with the rendered list
655    /// across future edits.
656    fn filtered_actions_for_empty_state(&self) -> Vec<JumpAction> {
657        let recent_keys: std::collections::HashSet<RecentRef> =
658            self.recents.iter().map(|h| h.identity()).collect();
659        JumpAction::for_mode(self.mode)
660            .iter()
661            .filter(|a| {
662                let id = RecentRef::new(SourceKind::Action, a.key.to_string());
663                !recent_keys.contains(&id)
664            })
665            .copied()
666            .collect()
667    }
668
669    /// Top-N actions for the empty-state, after `recent_keys` filtering
670    /// and the optional tab-bias. Single source of truth for both the
671    /// renderer (`empty_state_groups`) and the navigation handler
672    /// (`visible_hits`); without it the two would drift on the bias and
673    /// the cursor would land on different rows than the user sees.
674    fn empty_state_actions(&self) -> Vec<JumpHit> {
675        let filtered = self.filtered_actions_for_empty_state();
676        let preferred_target = match self.mode {
677            JumpMode::Hosts => None,
678            JumpMode::Tunnels => Some(JumpActionTarget::Tunnels),
679            JumpMode::Containers => Some(JumpActionTarget::Containers),
680            JumpMode::Keys => Some(JumpActionTarget::Keys),
681            // Snippet run-actions dispatch to the host list (target=Hosts), so
682            // they bias by label category instead of by dispatch target.
683            JumpMode::Snippets => None,
684        };
685        let actions = match self.mode {
686            JumpMode::Snippets => round_robin_actions_with_category_bias(
687                filtered.into_iter(),
688                "Snippets",
689                EMPTY_STATE_TAB_BIAS,
690            ),
691            _ => match preferred_target {
692                Some(t) => {
693                    round_robin_actions_with_bias(filtered.into_iter(), t, EMPTY_STATE_TAB_BIAS)
694                }
695                None => round_robin_actions_by_category(filtered.into_iter()),
696            },
697        };
698        actions
699            .into_iter()
700            .take(JUMP_EMPTY_STATE_ACTIONS_CAP)
701            .collect()
702    }
703
704    /// Number of actions available for the empty-state ACTIONS section
705    /// BEFORE the cap. Used by the renderer to render `Actions  6 of 29`
706    /// when the cap is applied.
707    pub fn empty_state_actions_total(&self) -> usize {
708        self.filtered_actions_for_empty_state().len()
709    }
710
711    /// Group `visible_hits()` for the query view: by `SourceKind` in render
712    /// order. Empty sections are omitted. Only meaningful when a query is
713    /// active; the empty-state view uses `empty_state_groups` instead.
714    pub fn grouped_hits(&self) -> Vec<(SourceKind, Vec<JumpHit>)> {
715        let visible = self.visible_hits();
716        let mut out = Vec::with_capacity(SourceKind::render_order().len());
717        for kind in SourceKind::render_order() {
718            let group: Vec<JumpHit> = visible
719                .iter()
720                .filter(|h| h.kind() == kind)
721                .cloned()
722                .collect();
723            if !group.is_empty() {
724                out.push((kind, group));
725            }
726        }
727        out
728    }
729
730    /// Empty-state grouping: a single `RECENT` group (everything that came
731    /// from the MRU log, of any kind) followed by an `ACTIONS` group.
732    /// Returns `(label, hits)` rather than `(kind, hits)` so the renderer
733    /// can distinguish "RECENT" from a per-kind label.
734    pub fn empty_state_groups(&self) -> Vec<(&'static str, Vec<JumpHit>)> {
735        let mut out: Vec<(&'static str, Vec<JumpHit>)> = Vec::new();
736        if !self.recents.is_empty() {
737            out.push(("RECENT", self.recents.clone()));
738        }
739        // Single source of truth shared with `visible_hits` so the
740        // navigation cursor and the rendered list cannot drift.
741        let actions = self.empty_state_actions();
742        if !actions.is_empty() {
743            out.push(("ACTIONS", actions));
744        }
745        out
746    }
747
748    /// Map `selected` index (into `visible_hits()`) to a `SourceKind` so the
749    /// renderer knows which section header is currently active.
750    pub fn selected_section(&self) -> Option<SourceKind> {
751        self.visible_hits().get(self.selected).map(|h| h.kind())
752    }
753
754    /// Return actions whose label substring-matches the current query.
755    /// Test-only shim for tests that predate the unified jump bar.
756    /// Production code iterates `visible_hits()` instead.
757    #[cfg(test)]
758    pub fn filtered_commands(&self) -> Vec<JumpAction> {
759        let all = JumpAction::for_mode(self.mode);
760        if self.query.is_empty() {
761            return all.to_vec();
762        }
763        let q = self.query.to_lowercase();
764        all.iter()
765            .filter(|cmd| {
766                cmd.label.to_lowercase().contains(&q)
767                    || cmd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
768            })
769            .copied()
770            .collect()
771    }
772
773    /// Move selection to the first hit in the next non-empty section. Wraps.
774    pub fn jump_next_section(&mut self) {
775        let visible = self.visible_hits();
776        if visible.is_empty() {
777            return;
778        }
779        if self.query.is_empty() {
780            // Empty-state has up to two groups: RECENT (length =
781            // recents.len()) and ACTIONS (the rest). Tab toggles between
782            // their first rows. Skip the toggle if there is no second
783            // group to jump to (e.g. no recents, or no actions after
784            // recents). The two `if` branches inside this block both fire
785            // in real cases: from RECENT row n we jump to actions; from
786            // an action row we wrap back to the first recent.
787            let n_recent = self.recents.len();
788            if n_recent == 0 || n_recent >= visible.len() {
789                return;
790            }
791            if self.selected < n_recent {
792                self.selected = n_recent; // RECENT -> ACTIONS
793            } else {
794                self.selected = 0; // ACTIONS -> first RECENT
795            }
796            return;
797        }
798        let groups = self.grouped_hits();
799        if groups.len() < 2 {
800            return;
801        }
802        let cur_kind = match self.selected_section() {
803            Some(k) => k,
804            None => {
805                self.selected = 0;
806                return;
807            }
808        };
809        let cur_idx = groups.iter().position(|(k, _)| *k == cur_kind).unwrap_or(0);
810        let next_idx = (cur_idx + 1) % groups.len();
811        let next_kind = groups[next_idx].0;
812        if let Some(pos) = visible.iter().position(|h| h.kind() == next_kind) {
813            self.selected = pos;
814        }
815    }
816}
817
818#[cfg(test)]
819pub mod tests {
820    use super::*;
821
822    // `test_path` is thread-local, so each test thread gets an isolated
823    // recents file with no shared state. No process-wide lock is needed.
824    fn with_temp<F: FnOnce(&Paths)>(f: F) {
825        let dir = tempfile::tempdir().unwrap();
826        let paths = Paths::new(dir.path());
827        f(&paths);
828    }
829
830    #[test]
831    fn snippets_mode_empty_state_leads_with_snippet_actions() {
832        // The Snippets tab opens jump in Snippets mode; the empty-state must
833        // bias the "Snippets:" run-actions to the front (they dispatch to the
834        // host list, so the bias is by label category, not by target).
835        let state = JumpState::for_mode(JumpMode::Snippets);
836        let hits = state.visible_hits();
837        match hits.first().expect("at least one action") {
838            JumpHit::Action(a) => assert!(
839                a.label.starts_with("Snippets:"),
840                "expected a Snippets action first, got {:?}",
841                a.label
842            ),
843            other => panic!("expected an action first, got {other:?}"),
844        }
845    }
846
847    #[test]
848    fn hosts_mode_empty_state_does_not_lead_with_snippet_actions() {
849        // Control: in the default Hosts mode the round-robin leads with the
850        // highest-priority category (Hosts), not Snippets.
851        let state = JumpState::for_mode(JumpMode::Hosts);
852        let hits = state.visible_hits();
853        if let Some(JumpHit::Action(a)) = hits.first() {
854            assert!(
855                !a.label.starts_with("Snippets:"),
856                "Hosts mode should not lead with a Snippets action, got {:?}",
857                a.label
858            );
859        }
860    }
861
862    #[test]
863    fn visible_hits_matches_grouped_render_order_with_active_query() {
864        // Regression for the jump-bar mis-dispatch: navigation (`move_down`)
865        // and dispatch index `visible_hits()`, while the renderer lays rows
866        // out in `grouped_hits()` order. When a query matches across sections
867        // the score-sorted flat list interleaves kinds differently from the
868        // fixed render order, so the highlighted row and the executed hit
869        // point at different items. Pin that the two orders agree.
870        let action = JumpAction::all()[0];
871        let host = HostHit {
872            alias: "proxy-vm".into(),
873            hostname: "proxy-vm.example.com".into(),
874            tags: Vec::new(),
875            provider: None,
876            user: String::new(),
877            identity_file: String::new(),
878            proxy_jump: String::new(),
879            vault_ssh: None,
880        };
881        // Score order puts the action first (a strong action match outranks a
882        // fuzzy host match). The renderer regroups HOSTS before ACTIONS.
883        let state = JumpState {
884            query: "prov".into(),
885            hits: vec![JumpHit::Action(action), JumpHit::Host(host)],
886            ..Default::default()
887        };
888
889        let visible = state.visible_hits();
890        let flattened: Vec<JumpHit> = state
891            .grouped_hits()
892            .into_iter()
893            .flat_map(|(_, hits)| hits)
894            .collect();
895        assert_eq!(
896            visible, flattened,
897            "visible_hits() must equal the flattened grouped order so the \
898             highlighted row and the dispatched hit reference the same item"
899        );
900        // Row 0 is what the selection cue lands on first; with HOSTS rendered
901        // before ACTIONS it must be the host, matching what the user sees.
902        assert!(
903            matches!(visible[0], JumpHit::Host(_)),
904            "first visible row must follow render order (HOSTS first)"
905        );
906    }
907
908    #[test]
909    fn section_labels_are_uppercase() {
910        for k in SourceKind::render_order() {
911            let label = k.section_label();
912            assert_eq!(label, label.to_uppercase(), "{:?} not uppercase", k);
913        }
914    }
915
916    #[test]
917    fn render_order_starts_with_hosts() {
918        assert_eq!(SourceKind::render_order()[0], SourceKind::Host);
919        assert_eq!(SourceKind::render_order()[4], SourceKind::Action);
920    }
921
922    #[test]
923    fn touch_moves_existing_to_front_and_caps() {
924        let mut f = RecentsFile::default();
925        for i in 0..(RECENTS_CAP + 5) {
926            touch_recent(&mut f, RecentRef::new(SourceKind::Host, format!("h{i}")));
927        }
928        assert_eq!(f.entries.len(), RECENTS_CAP);
929        // Re-touching an existing ref moves it to the front.
930        let target = RecentRef::new(SourceKind::Host, format!("h{}", RECENTS_CAP + 2));
931        touch_recent(&mut f, target.clone());
932        assert_eq!(f.entries[0].target, target);
933        assert_eq!(f.entries.len(), RECENTS_CAP);
934    }
935
936    #[test]
937    fn save_then_load_roundtrip() {
938        with_temp(|paths| {
939            let mut f = RecentsFile::default();
940            touch_recent(&mut f, RecentRef::new(SourceKind::Action, "F".into()));
941            touch_recent(&mut f, RecentRef::new(SourceKind::Host, "web-01".into()));
942            save_recents(&f, Some(paths)).expect("save");
943            let loaded = load_recents(Some(paths));
944            assert_eq!(loaded.version, RECENTS_VERSION);
945            assert_eq!(loaded.entries.len(), 2);
946            assert_eq!(loaded.entries[0].target.key, "web-01");
947            assert_eq!(loaded.entries[1].target.key, "F");
948        });
949    }
950
951    #[test]
952    fn missing_file_loads_empty() {
953        with_temp(|paths| {
954            let loaded = load_recents(Some(paths));
955            assert!(loaded.entries.is_empty());
956        });
957    }
958
959    #[test]
960    fn corrupt_file_loads_empty() {
961        with_temp(|paths| {
962            let path = paths.recents();
963            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
964            std::fs::write(&path, b"not json").unwrap();
965            let loaded = load_recents(Some(paths));
966            assert!(loaded.entries.is_empty());
967        });
968    }
969
970    fn host_entry(alias: &str, ts: i64) -> RecentEntry {
971        RecentEntry {
972            target: RecentRef::new(SourceKind::Host, alias.to_string()),
973            last_used_unix: ts,
974        }
975    }
976
977    #[test]
978    fn rename_host_recent_rewrites_key() {
979        let mut file = RecentsFile::default();
980        file.entries.push(host_entry("web-old", 100));
981        file.entries.push(RecentEntry {
982            target: RecentRef::new(SourceKind::Tunnel, "web-old:5432".to_string()),
983            last_used_unix: 90,
984        });
985
986        assert!(rename_host_recent(&mut file, "web-old", "web-new"));
987        assert_eq!(file.entries[0].target.kind, SourceKind::Host);
988        assert_eq!(file.entries[0].target.key, "web-new");
989        // Non-host entries with a coincidental key prefix are untouched.
990        assert_eq!(file.entries[1].target.kind, SourceKind::Tunnel);
991        assert_eq!(file.entries[1].target.key, "web-old:5432");
992    }
993
994    #[test]
995    fn rename_host_recent_dedups_on_collision_keeping_most_recent() {
996        let mut file = RecentsFile::default();
997        // Old entry is more recent. After rename the newer timestamp must
998        // survive and the older duplicate must be dropped.
999        file.entries.push(host_entry("a", 200));
1000        file.entries.push(host_entry("b", 100));
1001
1002        assert!(rename_host_recent(&mut file, "a", "b"));
1003        assert_eq!(file.entries.len(), 1);
1004        assert_eq!(file.entries[0].target.key, "b");
1005        assert_eq!(file.entries[0].last_used_unix, 200);
1006    }
1007
1008    #[test]
1009    fn rename_host_recent_dedups_when_new_key_is_newer() {
1010        let mut file = RecentsFile::default();
1011        file.entries.push(host_entry("a", 100));
1012        file.entries.push(host_entry("b", 200));
1013
1014        assert!(rename_host_recent(&mut file, "a", "b"));
1015        assert_eq!(file.entries.len(), 1);
1016        assert_eq!(file.entries[0].target.key, "b");
1017        assert_eq!(file.entries[0].last_used_unix, 200);
1018    }
1019
1020    #[test]
1021    fn rename_host_recent_noop_when_same() {
1022        let mut file = RecentsFile::default();
1023        file.entries.push(host_entry("a", 10));
1024        assert!(!rename_host_recent(&mut file, "a", "a"));
1025        assert_eq!(file.entries.len(), 1);
1026    }
1027
1028    #[test]
1029    fn rename_host_recent_noop_when_absent() {
1030        let mut file = RecentsFile::default();
1031        assert!(!rename_host_recent(&mut file, "ghost", "phantom"));
1032        assert!(file.entries.is_empty());
1033    }
1034}