Skip to main content

purple_ssh/app/
ui_state.rs

1//! UI selection substate: list cursors, picker overlays, scroll offsets.
2
3use ratatui::widgets::ListState;
4
5use crate::ui::theme::ThemeDef;
6
7/// A picker overlay: open flag plus its list cursor.
8#[derive(Debug, Default)]
9pub struct PickerState {
10    pub open: bool,
11    pub list: ListState,
12}
13
14#[allow(dead_code)]
15impl PickerState {
16    /// Open the picker with the cursor positioned at `index`.
17    pub fn open_at(&mut self, index: usize) {
18        self.open = true;
19        self.list.select(Some(index));
20    }
21
22    /// Close the picker and reset the cursor.
23    pub fn close(&mut self) {
24        self.open = false;
25        self.list.select(None);
26    }
27}
28
29/// Theme picker carries extra catalogue + preview state beyond a simple list.
30#[derive(Debug, Default)]
31pub struct ThemePickerState {
32    pub list: ListState,
33    pub builtins: Vec<ThemeDef>,
34    pub custom: Vec<ThemeDef>,
35    pub saved_name: String,
36    pub original: Option<ThemeDef>,
37}
38
39/// Region picker uses a cursor index rather than a ListState because region
40/// rows are a synthetic flat array (provider × region pairs) rather than a
41/// ratatui-managed list.
42#[derive(Debug, Default)]
43pub struct RegionPickerState {
44    pub open: bool,
45    pub cursor: usize,
46}
47
48/// What the AWS profile picker says about one profile beyond its name.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AwsProfileNote {
51    pub text: &'static str,
52    /// False when the chain does not resolve, so the row reads as a warning
53    /// rather than as an ordinary annotation.
54    pub usable: bool,
55}
56
57/// One row of the AWS profile picker, built once when the overlay opens.
58///
59/// Cached rather than derived per frame: building a row reads both AWS files
60/// and resolves the profile's chain, and the overlay redraws on every tick.
61/// Every sibling picker scans on open for the same reason.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct AwsProfileRow {
64    pub name: String,
65    pub note: Option<AwsProfileNote>,
66    /// The profile's own `region`, so picking it can fill the Regions field
67    /// without reading `~/.aws` a second time.
68    pub region: String,
69}
70
71#[derive(Debug, Default)]
72pub struct UiSelection {
73    pub(in crate::app) list_state: ListState,
74    pub(in crate::app) key_picker: PickerState,
75    pub(in crate::app) password_picker: PickerState,
76    pub(in crate::app) proxyjump_picker: PickerState,
77    pub(in crate::app) vault_role_picker: PickerState,
78    pub(in crate::app) profile_picker: PickerState,
79    /// The profile picker's rows, read when it opens. The list cursor above
80    /// indexes into this.
81    pub(in crate::app) aws_profile_rows: Vec<AwsProfileRow>,
82    pub(in crate::app) tag_picker_state: ListState,
83    pub(in crate::app) bulk_tag_editor_state: ListState,
84    pub(in crate::app) theme_picker: ThemePickerState,
85    pub(in crate::app) provider_list_state: ListState,
86    pub(in crate::app) tunnel_list_state: ListState,
87    pub(in crate::app) tunnels_overview_state: ListState,
88    pub(in crate::app) containers_overview_state: ListState,
89    /// Cursor for the host picker reached from the tunnels overview when
90    /// adding a new tunnel. Indexes into the editable-hosts slice built at
91    /// render time (hosts from included files are excluded).
92    pub(in crate::app) tunnel_host_picker_state: ListState,
93    /// Live fuzzy-search query for the tunnel host picker. Always-on input
94    /// mode: every printable keystroke appends to the query and shrinks the
95    /// candidate set. Empty string means "show all".
96    pub(in crate::app) tunnel_host_picker_query: String,
97    /// Cursor + live query for the containers-tab `a` host picker.
98    /// Mirrors the tunnel host picker pair; kept separate so the two
99    /// pickers can be open back-to-back without state bleed.
100    pub(in crate::app) container_host_picker_state: ListState,
101    pub(in crate::app) container_host_picker_query: String,
102    pub(in crate::app) snippet_picker_state: ListState,
103    pub(in crate::app) snippet_search: Option<String>,
104    pub(in crate::app) region_picker: RegionPickerState,
105    pub(in crate::app) help_scroll: u16,
106    pub(in crate::app) detail_scroll: u16,
107    /// Set by handler, consumed by AnimationState to trigger detail panel transition.
108    pub(in crate::app) detail_toggle_pending: bool,
109    /// Tracks when the welcome screen was opened to auto-dismiss it.
110    pub(in crate::app) welcome_opened: Option<std::time::Instant>,
111    /// Set once the first time Esc-on-empty-list hint is shown per process.
112    pub(in crate::app) esc_quit_hint_shown: bool,
113    /// Welcome-screen heuristic: number of known hosts at last render.
114    pub(in crate::app) known_hosts_count: usize,
115    /// Pending SSH dispatch queued by connect actions; consumed by the event loop.
116    pub(in crate::app) pending_connect: Option<(String, Option<String>)>,
117}
118
119impl UiSelection {
120    /// Construct with all picker/list state defaulted and the host list
121    /// selection pre-positioned at `initial` (the first selectable host or
122    /// pattern in the display list).
123    pub fn new_with_initial_selection(initial: Option<usize>) -> Self {
124        let mut s = Self::default();
125        if let Some(pos) = initial {
126            s.list_state.select(Some(pos));
127        }
128        s
129    }
130
131    /// Queue an SSH connect for the event loop to pick up via the next
132    /// `pending_connect.take()`. `askpass` carries the resolved per-host
133    /// password source so the event loop can prepare a SSH_ASKPASS env
134    /// before spawning the child.
135    pub fn queue_connect(&mut self, alias: String, askpass: Option<String>) {
136        self.pending_connect = Some((alias, askpass));
137    }
138
139    /// Enter snippet picker search mode with an empty query.
140    pub fn open_snippet_search(&mut self) {
141        self.snippet_search = Some(String::new());
142    }
143
144    /// Exit snippet picker search mode. Idempotent.
145    pub fn close_snippet_search(&mut self) {
146        self.snippet_search = None;
147    }
148
149    pub fn list_state(&self) -> &ListState {
150        &self.list_state
151    }
152
153    pub fn list_state_mut(&mut self) -> &mut ListState {
154        &mut self.list_state
155    }
156
157    pub fn key_picker(&self) -> &PickerState {
158        &self.key_picker
159    }
160
161    pub fn key_picker_mut(&mut self) -> &mut PickerState {
162        &mut self.key_picker
163    }
164
165    pub fn profile_picker(&self) -> &PickerState {
166        &self.profile_picker
167    }
168
169    pub fn profile_picker_mut(&mut self) -> &mut PickerState {
170        &mut self.profile_picker
171    }
172
173    pub fn aws_profile_rows(&self) -> &[AwsProfileRow] {
174        &self.aws_profile_rows
175    }
176
177    pub fn password_picker(&self) -> &PickerState {
178        &self.password_picker
179    }
180
181    pub fn password_picker_mut(&mut self) -> &mut PickerState {
182        &mut self.password_picker
183    }
184
185    pub fn proxyjump_picker(&self) -> &PickerState {
186        &self.proxyjump_picker
187    }
188
189    pub fn proxyjump_picker_mut(&mut self) -> &mut PickerState {
190        &mut self.proxyjump_picker
191    }
192
193    pub fn vault_role_picker(&self) -> &PickerState {
194        &self.vault_role_picker
195    }
196
197    pub fn vault_role_picker_mut(&mut self) -> &mut PickerState {
198        &mut self.vault_role_picker
199    }
200
201    pub fn tag_picker_state(&self) -> &ListState {
202        &self.tag_picker_state
203    }
204
205    pub fn tag_picker_state_mut(&mut self) -> &mut ListState {
206        &mut self.tag_picker_state
207    }
208
209    pub fn bulk_tag_editor_state(&self) -> &ListState {
210        &self.bulk_tag_editor_state
211    }
212
213    pub fn bulk_tag_editor_state_mut(&mut self) -> &mut ListState {
214        &mut self.bulk_tag_editor_state
215    }
216
217    pub fn theme_picker(&self) -> &ThemePickerState {
218        &self.theme_picker
219    }
220
221    pub fn theme_picker_mut(&mut self) -> &mut ThemePickerState {
222        &mut self.theme_picker
223    }
224
225    pub fn provider_list_state(&self) -> &ListState {
226        &self.provider_list_state
227    }
228
229    pub fn provider_list_state_mut(&mut self) -> &mut ListState {
230        &mut self.provider_list_state
231    }
232
233    pub fn tunnel_list_state(&self) -> &ListState {
234        &self.tunnel_list_state
235    }
236
237    pub fn tunnel_list_state_mut(&mut self) -> &mut ListState {
238        &mut self.tunnel_list_state
239    }
240
241    pub fn tunnels_overview_state(&self) -> &ListState {
242        &self.tunnels_overview_state
243    }
244
245    pub fn tunnels_overview_state_mut(&mut self) -> &mut ListState {
246        &mut self.tunnels_overview_state
247    }
248
249    pub fn containers_overview_state(&self) -> &ListState {
250        &self.containers_overview_state
251    }
252
253    pub fn containers_overview_state_mut(&mut self) -> &mut ListState {
254        &mut self.containers_overview_state
255    }
256
257    pub fn tunnel_host_picker_state(&self) -> &ListState {
258        &self.tunnel_host_picker_state
259    }
260
261    pub fn tunnel_host_picker_state_mut(&mut self) -> &mut ListState {
262        &mut self.tunnel_host_picker_state
263    }
264
265    pub fn tunnel_host_picker_query(&self) -> &String {
266        &self.tunnel_host_picker_query
267    }
268
269    pub fn tunnel_host_picker_query_mut(&mut self) -> &mut String {
270        &mut self.tunnel_host_picker_query
271    }
272
273    pub fn set_tunnel_host_picker_query(&mut self, query: String) {
274        self.tunnel_host_picker_query = query;
275    }
276
277    pub fn container_host_picker_state(&self) -> &ListState {
278        &self.container_host_picker_state
279    }
280
281    pub fn container_host_picker_state_mut(&mut self) -> &mut ListState {
282        &mut self.container_host_picker_state
283    }
284
285    pub fn container_host_picker_query(&self) -> &String {
286        &self.container_host_picker_query
287    }
288
289    pub fn container_host_picker_query_mut(&mut self) -> &mut String {
290        &mut self.container_host_picker_query
291    }
292
293    pub fn set_container_host_picker_query(&mut self, query: String) {
294        self.container_host_picker_query = query;
295    }
296
297    pub fn snippet_picker_state(&self) -> &ListState {
298        &self.snippet_picker_state
299    }
300
301    pub fn snippet_picker_state_mut(&mut self) -> &mut ListState {
302        &mut self.snippet_picker_state
303    }
304
305    pub fn snippet_search(&self) -> Option<&String> {
306        self.snippet_search.as_ref()
307    }
308
309    pub fn snippet_search_mut(&mut self) -> Option<&mut String> {
310        self.snippet_search.as_mut()
311    }
312
313    pub fn region_picker(&self) -> &RegionPickerState {
314        &self.region_picker
315    }
316
317    pub fn region_picker_mut(&mut self) -> &mut RegionPickerState {
318        &mut self.region_picker
319    }
320
321    pub fn help_scroll(&self) -> u16 {
322        self.help_scroll
323    }
324
325    pub fn set_help_scroll(&mut self, scroll: u16) {
326        self.help_scroll = scroll;
327    }
328
329    pub fn detail_scroll(&self) -> u16 {
330        self.detail_scroll
331    }
332
333    pub fn set_detail_scroll(&mut self, scroll: u16) {
334        self.detail_scroll = scroll;
335    }
336
337    pub fn detail_toggle_pending(&self) -> bool {
338        self.detail_toggle_pending
339    }
340
341    pub fn set_detail_toggle_pending(&mut self, pending: bool) {
342        self.detail_toggle_pending = pending;
343    }
344
345    pub fn welcome_opened(&self) -> Option<std::time::Instant> {
346        self.welcome_opened
347    }
348
349    pub fn set_welcome_opened(&mut self, when: Option<std::time::Instant>) {
350        self.welcome_opened = when;
351    }
352
353    pub fn esc_quit_hint_shown(&self) -> bool {
354        self.esc_quit_hint_shown
355    }
356
357    pub fn set_esc_quit_hint_shown(&mut self, shown: bool) {
358        self.esc_quit_hint_shown = shown;
359    }
360
361    pub fn known_hosts_count(&self) -> usize {
362        self.known_hosts_count
363    }
364
365    pub fn set_known_hosts_count(&mut self, count: usize) {
366        self.known_hosts_count = count;
367    }
368
369    pub fn pending_connect(&self) -> Option<&(String, Option<String>)> {
370        self.pending_connect.as_ref()
371    }
372
373    /// Take the queued connect, leaving `None`. Consumed by the event loop.
374    pub fn take_pending_connect(&mut self) -> Option<(String, Option<String>)> {
375        self.pending_connect.take()
376    }
377}
378
379impl ThemePickerState {
380    /// Clear the catalogue lists and the saved-name input. Used by both
381    /// picker-close paths. The `list` cursor and `original` are
382    /// intentionally NOT touched here. The two callers handle `original`
383    /// differently: the Esc/q path consumes it via `.take()` before
384    /// calling reset (to restore the prior live theme); the Enter-save
385    /// path leaves `original` intact through reset and clears it
386    /// explicitly afterwards. Keeping `reset` orthogonal to `original`
387    /// lets both flows share the same body.
388    pub fn reset(&mut self) {
389        self.builtins = Vec::new();
390        self.custom = Vec::new();
391        self.saved_name = String::new();
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn queue_connect_sets_pending_connect_to_some() {
401        let mut s = UiSelection::default();
402        s.queue_connect("web1".into(), Some("vault:foo".into()));
403        assert_eq!(
404            s.pending_connect,
405            Some(("web1".to_string(), Some("vault:foo".to_string())))
406        );
407    }
408
409    #[test]
410    fn queue_connect_with_no_askpass_stores_none() {
411        let mut s = UiSelection::default();
412        s.queue_connect("web1".into(), None);
413        assert_eq!(s.pending_connect, Some(("web1".to_string(), None)));
414    }
415
416    #[test]
417    fn queue_connect_overwrites_existing_pending() {
418        let mut s = UiSelection::default();
419        s.queue_connect("first".into(), None);
420        s.queue_connect("second".into(), Some("p".into()));
421        assert_eq!(
422            s.pending_connect,
423            Some(("second".to_string(), Some("p".to_string())))
424        );
425    }
426
427    #[test]
428    fn open_snippet_search_sets_empty_query() {
429        let mut s = UiSelection::default();
430        s.open_snippet_search();
431        assert_eq!(s.snippet_search.as_deref(), Some(""));
432    }
433
434    #[test]
435    fn open_snippet_search_overwrites_existing_query_with_empty() {
436        // The handler currently calls open only when search was inactive,
437        // but the invariant should still hold: open is unconditional and
438        // resets to an empty query. Pin the reset semantic so a future
439        // caller cannot rely on a preserved query.
440        let mut s = UiSelection {
441            snippet_search: Some("old".to_string()),
442            ..Default::default()
443        };
444        s.open_snippet_search();
445        assert_eq!(s.snippet_search.as_deref(), Some(""));
446    }
447
448    #[test]
449    fn close_snippet_search_clears_query() {
450        let mut s = UiSelection {
451            snippet_search: Some("query".to_string()),
452            ..Default::default()
453        };
454        s.close_snippet_search();
455        assert!(s.snippet_search.is_none());
456    }
457
458    #[test]
459    fn close_snippet_search_is_idempotent() {
460        let mut s = UiSelection::default();
461        s.close_snippet_search();
462        s.close_snippet_search();
463        assert!(s.snippet_search.is_none());
464    }
465
466    #[test]
467    fn theme_picker_reset_clears_lists_and_saved_name() {
468        let mut t = ThemePickerState {
469            builtins: vec![ThemeDef::purple_purple()],
470            custom: vec![ThemeDef::purple_purple(), ThemeDef::purple_purple()],
471            saved_name: "Solarized".to_string(),
472            ..Default::default()
473        };
474        t.reset();
475        assert!(t.builtins.is_empty());
476        assert!(t.custom.is_empty());
477        assert!(t.saved_name.is_empty());
478    }
479
480    #[test]
481    fn theme_picker_reset_preserves_original_and_list_cursor() {
482        let mut t = ThemePickerState {
483            builtins: vec![ThemeDef::purple_purple()],
484            original: Some(ThemeDef::purple_purple()),
485            ..Default::default()
486        };
487        t.list.select(Some(2));
488        t.reset();
489        assert!(t.original.is_some(), "original must survive reset()");
490        assert_eq!(t.list.selected(), Some(2));
491    }
492}