Skip to main content

rusticity_term/ui/
status.rs

1use crate::app::{App, Service, ViewMode};
2use crate::keymap::Mode;
3use crate::ui::cfn::DetailTab;
4use crate::ui::red_text;
5use ratatui::{prelude::*, widgets::*};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
9
10pub fn first_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
11    vec![
12        Span::styled(key, red_text()),
13        Span::raw(" "),
14        Span::raw(action),
15        Span::raw(" ⋮"),
16    ]
17}
18
19pub fn hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
20    vec![
21        Span::raw(" "),
22        Span::styled(key, red_text()),
23        Span::raw(" "),
24        Span::raw(action),
25        Span::raw(" ⋮"),
26    ]
27}
28
29pub fn last_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
30    vec![
31        Span::raw(" "),
32        Span::styled(key, red_text()),
33        Span::raw(" "),
34        Span::raw(action),
35    ]
36}
37
38fn common_detail_hotkeys() -> Vec<Span<'static>> {
39    let mut spans = vec![];
40    spans.extend(first_hint("↑↓", "scroll"));
41    spans.extend(hint("⎋", "back"));
42    spans.extend(hint("[]", "switch"));
43    spans.extend(hint("⇤⇥", "switch"));
44    spans.extend(hint("^u", "page up"));
45    spans.extend(hint("^d", "page down"));
46    spans.extend(hint("y", "yank"));
47    spans.extend(hint("^o", "console"));
48    spans.extend(hint("p", "preferences"));
49    spans.extend(hint("^p", "print"));
50    spans.extend(hint("^r", "refresh"));
51    spans.extend(hint("^w", "close"));
52    spans.extend(last_hint("q", "quit"));
53    spans
54}
55
56fn common_list_hotkeys() -> Vec<Span<'static>> {
57    let mut spans = vec![];
58    spans.extend(first_hint("↑↓", "scroll"));
59    spans.extend(hint("←→", "toggle"));
60    spans.extend(hint("⏎", "open"));
61    spans.extend(hint("[]", "switch"));
62    spans.extend(hint("^u", "page up"));
63    spans.extend(hint("^d", "page down"));
64    spans.extend(hint("y", "yank"));
65    spans.extend(hint("^o", "console"));
66    spans.extend(hint("p", "preferences"));
67    spans.extend(hint("^p", "print"));
68    spans.extend(hint("^r", "refresh"));
69    spans.extend(hint("^w", "close"));
70    spans.extend(last_hint("q", "quit"));
71    spans
72}
73
74pub fn render_bottom_bar(frame: &mut Frame, app: &App, area: Rect) {
75    let is_insert = match app.mode {
76        Mode::FilterInput | Mode::EventFilterInput | Mode::InsightsInput => true,
77        Mode::ServicePicker | Mode::SpaceMenu => app.service_picker.filter_active,
78        Mode::RegionPicker => app.region_filter_active,
79        Mode::SessionPicker => app.session_filter_active,
80        Mode::ProfilePicker => app.profile_filter_active,
81        _ => false,
82    };
83
84    let mode_indicator = if is_insert { " INSERT " } else { " NORMAL " };
85    let mode_style = if is_insert {
86        Style::default().bg(Color::Yellow).fg(Color::Black)
87    } else {
88        Style::default().bg(Color::Blue).fg(Color::White)
89    };
90
91    let help = if app.mode == Mode::QuitConfirm {
92        let mut hints = vec![];
93        hints.extend(first_hint("y", "confirm quit"));
94        hints.extend(last_hint("n / Esc", "cancel"));
95        hints
96    } else if app.mode == Mode::ColumnSelector {
97        let mut hints = vec![];
98        hints.extend(hint("↑↓", "scroll"));
99        hints.extend(hint("␣", "toggle"));
100
101        if app.current_service == Service::CloudWatchAlarms {
102            hints.extend(hint("⇤⇥", "switch"));
103        }
104
105        hints.extend(last_hint("⎋", "close"));
106        hints
107    } else if app.mode == Mode::InsightsInput {
108        let mut hints = vec![];
109        hints.extend(hint(" tab", "switch"));
110        hints.extend(hint("↑↓", "scroll"));
111        hints.extend(hint("␣", "toggle"));
112        hints.extend(hint("⏎", "execute"));
113        hints.extend(hint("⎋", "cancel"));
114        hints.extend(hint("^r", "refresh"));
115        hints.extend(last_hint("^w", "close"));
116        hints
117    } else if app.current_service == Service::CloudWatchInsights {
118        let mut hints = vec![];
119        hints.extend(hint(" i", "insert"));
120        hints.extend(hint("⏎", "execute"));
121        hints.extend(hint("?", "help"));
122        hints.extend(hint("^r", "refresh"));
123        hints.extend(hint("^o", "console"));
124        hints.extend(hint("⎋", "back"));
125        hints.extend(hint("^w", "close"));
126        hints.extend(last_hint("q", "quit"));
127        hints
128    } else if app.mode == Mode::EventFilterInput {
129        let mut hints = vec![];
130        hints.extend(first_hint("⇤⇥", "switch"));
131        hints.extend(hint("␣", "change unit"));
132        hints.extend(hint("⏎", "apply"));
133        hints.extend(hint("⎋", "cancel"));
134        hints.extend(last_hint("^w", "close"));
135        hints
136    } else if app.mode == Mode::RegionPicker {
137        let mut hints = vec![];
138        hints.extend(first_hint("↑↓", "scroll"));
139        hints.extend(hint("i", "filter"));
140        hints.extend(hint("⏎", "select"));
141        hints.extend(hint("^l", "measure latency"));
142        hints.extend(hint("⎋", "close"));
143        hints.extend(last_hint("q", "quit"));
144        hints
145    } else if app.mode == Mode::FilterInput {
146        let mut hints = vec![];
147        hints.extend(first_hint("⏎", "apply"));
148        hints.extend(hint("⎋", "cancel"));
149        hints.extend(hint("␣", "toggle"));
150        hints.extend(hint("⇤⇥", "switch"));
151        hints.extend(last_hint("^w", "close"));
152        hints
153    } else if app.view_mode == ViewMode::Events {
154        let mut hints = vec![];
155        hints.extend(first_hint("↑↓", "scroll"));
156        hints.extend(hint("←→", "toggle"));
157        hints.extend(hint("y", "yank"));
158        hints.extend(hint("^o", "console"));
159        hints.extend(hint("^r", "refresh"));
160        hints.extend(hint("p", "preferences"));
161        hints.extend(hint("^p", "print"));
162        hints.extend(hint("^w", "close"));
163        hints.extend(last_hint("q", "quit"));
164        hints
165    } else if app.current_service == Service::CloudWatchAlarms
166        && app.alarms_state.current_alarm.is_some()
167    {
168        let mut hints = vec![];
169        hints.extend(first_hint("↑↓", "scroll"));
170        hints.extend(hint("⇤⇥", "switch tab"));
171        hints.extend(hint("⎋", "back"));
172        hints.extend(hint("y", "copy JSON"));
173        hints.extend(hint("^o", "console"));
174        hints.extend(hint("^r", "refresh"));
175        hints.extend(hint("^w", "close"));
176        hints.extend(last_hint("q", "quit"));
177        hints
178    } else if app.view_mode == ViewMode::Detail {
179        let mut hints = vec![];
180        hints.extend(first_hint("↑↓", "scroll"));
181        hints.extend(hint("←→", "toggle"));
182        hints.extend(hint("⏎", "open"));
183        hints.extend(hint("⎋", "back"));
184        hints.extend(hint("i", "insert"));
185        hints.extend(hint("s", "sort"));
186        hints.extend(hint("o", "order"));
187        hints.extend(hint("<num>p", "page"));
188        hints.extend(hint("^o", "console"));
189        hints.extend(hint("⇤⇥", "switch"));
190        hints.extend(hint("^r", "refresh"));
191        hints.extend(hint("p", "preferences"));
192        hints.extend(hint("^p", "print"));
193        hints.extend(hint("^w", "close"));
194        hints.extend(last_hint("q", "quit"));
195        hints
196    } else if app.current_service == Service::EcrRepositories {
197        if app.ecr_state.current_repository.is_some() {
198            let mut hints = vec![];
199            hints.extend(first_hint("↑↓", "scroll"));
200            hints.extend(hint("←→", "toggle"));
201            hints.extend(hint("⎋", "back"));
202            hints.extend(hint("y", "yank"));
203            hints.extend(hint("^o", "console"));
204            hints.extend(hint("^r", "refresh"));
205            hints.extend(hint("^w", "close"));
206            hints.extend(last_hint("q", "quit"));
207            hints
208        } else {
209            let mut hints = vec![];
210            hints.extend(first_hint("↑↓", "scroll"));
211            hints.extend(hint("←→", "toggle"));
212            hints.extend(hint("⏎", "open"));
213            hints.extend(hint("⇤⇥", "switch"));
214            hints.extend(hint("y", "yank"));
215            hints.extend(hint("^o", "console"));
216            hints.extend(hint("^r", "refresh"));
217            hints.extend(hint("^w", "close"));
218            hints.extend(last_hint("q", "quit"));
219            hints
220        }
221    } else if app.current_service == Service::S3Buckets {
222        if app.s3_state.current_bucket.is_some() {
223            let mut hints = vec![];
224            hints.extend(first_hint("↑↓", "scroll"));
225            hints.extend(hint("←→", "toggle"));
226            hints.extend(hint("⏎", "open"));
227            hints.extend(hint("⎋", "back"));
228            hints.extend(hint("⇤⇥", "switch"));
229            hints.extend(hint("^o", "console"));
230            hints.extend(hint("^r", "refresh"));
231            hints.extend(hint("^w", "close"));
232            hints.extend(last_hint("q", "quit"));
233            hints
234        } else {
235            let mut hints = vec![];
236            hints.extend(first_hint("↑↓", "scroll"));
237            hints.extend(hint("←→", "toggle"));
238            hints.extend(hint("⏎", "open"));
239            hints.extend(hint("⇤⇥", "switch"));
240            hints.extend(hint("^o", "console"));
241            hints.extend(hint("^r", "refresh"));
242            hints.extend(hint("^w", "close"));
243            hints.extend(last_hint("q", "quit"));
244            hints
245        }
246    } else if app.current_service == Service::CloudFormationStacks {
247        if app.cfn_state.current_stack.is_some() {
248            // In stack detail view - customize hints based on tab
249            if app.cfn_state.detail_tab == DetailTab::Template
250                || app.cfn_state.detail_tab == DetailTab::GitSync
251            {
252                // Template and GitSync tabs: no preferences
253                let mut hints = vec![];
254                hints.extend(first_hint("↑↓", "scroll"));
255                hints.extend(hint("⎋", "back"));
256                hints.extend(hint("[]", "switch"));
257                hints.extend(hint("⇤⇥", "switch"));
258                hints.extend(hint("^u", "page up"));
259                hints.extend(hint("^d", "page down"));
260                hints.extend(hint("y", "yank"));
261                hints.extend(hint("^o", "console"));
262                hints.extend(hint("^r", "refresh"));
263                hints.extend(hint("^w", "close"));
264                hints.extend(last_hint("q", "quit"));
265                hints
266            } else {
267                common_detail_hotkeys()
268            }
269        } else {
270            // In stack list view - build custom hints with snapshot
271            let mut hints = vec![];
272            hints.extend(first_hint("↑↓", "scroll"));
273            hints.extend(hint("←→", "toggle"));
274            hints.extend(hint("⏎", "open"));
275            hints.extend(hint("[]", "switch"));
276            hints.extend(hint("^u", "page up"));
277            hints.extend(hint("^d", "page down"));
278            hints.extend(hint("y", "yank"));
279            hints.extend(hint("^p", "snapshot"));
280            hints.extend(hint("^o", "console"));
281            hints.extend(hint("^r", "refresh"));
282            hints.extend(hint("^w", "close"));
283            hints.extend(last_hint("q", "quit"));
284            hints
285        }
286    } else if app.current_service == Service::IamUsers {
287        if app.iam_state.current_user.is_some() {
288            common_detail_hotkeys()
289        } else {
290            common_list_hotkeys()
291        }
292    } else if app.current_service == Service::IamRoles {
293        if app.iam_state.current_role.is_some() {
294            common_detail_hotkeys()
295        } else {
296            common_list_hotkeys()
297        }
298    } else if app.current_service == Service::LambdaFunctions {
299        if app.lambda_state.current_function.is_some() {
300            common_detail_hotkeys()
301        } else {
302            common_list_hotkeys()
303        }
304    } else if app.view_mode == ViewMode::List {
305        let mut hints = vec![];
306        hints.extend(first_hint("↑↓", "scroll"));
307        hints.extend(hint("←→", "toggle"));
308        hints.extend(hint("⏎", "open"));
309        hints.extend(hint("^o", "console"));
310        hints.extend(hint("p", "preferences"));
311        hints.extend(hint("^p", "print"));
312        hints.extend(hint("^r", "refresh"));
313        hints.extend(hint("^w", "close"));
314        hints.extend(last_hint("q", "quit"));
315        hints
316    } else {
317        let mut hints = vec![];
318        hints.extend(first_hint("↑↓", "scroll"));
319        hints.extend(hint("←→", "toggle"));
320        hints.extend(hint("⏎", "open"));
321        hints.extend(hint("⎋", "back"));
322        hints.extend(hint("<num>p", "page"));
323        hints.extend(hint("^o", "console"));
324        hints.extend(hint("^r", "refresh"));
325        hints.extend(hint("p", "preferences"));
326        hints.extend(hint("^p", "print"));
327        hints.extend(hint("^w", "close"));
328        hints.extend(last_hint("q", "quit"));
329        hints
330    };
331
332    let millis = SystemTime::now()
333        .duration_since(UNIX_EPOCH)
334        .unwrap()
335        .as_millis();
336
337    // Any service loading → show generic spinner inline with version on the right
338    let is_loading = app.log_groups_state.loading
339        || app.kms_state.keys.loading
340        || app.ecr_state.repositories.loading
341        || app.ec2_state.table.loading
342        || app.cfn_state.table.loading
343        || app.lambda_state.table.loading
344        || app.lambda_application_state.table.loading
345        || app.alarms_state.table.loading
346        || app.cloudtrail_state.table.loading
347        || app.apig_state.apis.loading
348        || app.efs_state.file_systems.loading
349        || app.sqs_state.queues.loading;
350
351    let spinner_frame = if is_loading {
352        SPINNER_FRAMES[(millis / 100 % SPINNER_FRAMES.len() as u128) as usize]
353    } else {
354        " "
355    };
356
357    // Only show a left-side connection message (profile/region change), not generic loading noise
358    let connection_message: Option<String> =
359        if app.log_groups_state.loading && !app.log_groups_state.loading_message.is_empty() {
360            let msg = &app.log_groups_state.loading_message;
361            // Only connection messages, not "Refreshing..." noise
362            if msg.starts_with("Connecting") || msg.starts_with("Loading log") {
363                let max_width = area.width.saturating_sub(10) as usize;
364                Some(if msg.len() > max_width {
365                    format!("{}...", &msg[..max_width.saturating_sub(3)])
366                } else {
367                    msg.clone()
368                })
369            } else {
370                None
371            }
372        } else {
373            None
374        };
375
376    let status_line_temp = if let Some(msg) = connection_message {
377        Some(Line::from(vec![Span::raw(msg)]))
378    } else if !app.page_input.is_empty() {
379        Some(Line::from(vec![Span::raw(format!(
380            "Go to page {} (press p to confirm)",
381            app.page_input
382        ))]))
383    } else {
384        None
385    };
386
387    let chunks = Layout::default()
388        .direction(Direction::Horizontal)
389        .constraints([
390            Constraint::Length(8),
391            Constraint::Length(2),
392            Constraint::Min(0),
393        ])
394        .split(area);
395
396    let mode_widget = Paragraph::new(mode_indicator).style(mode_style);
397
398    let spinner_widget = Paragraph::new(spinner_frame)
399        .block(Block::default())
400        .style(Style::default().bg(Color::DarkGray).fg(Color::Yellow));
401
402    // Build version string
403    let version = env!("CARGO_PKG_VERSION");
404    let commit = option_env!("GIT_HASH").unwrap_or("unknown");
405    let version_text = format!("⋮ RUSTICITY v{} (#{})", version, commit);
406
407    let colors = [
408        Color::Red,
409        Color::Green,
410        Color::Yellow,
411        Color::Blue,
412        Color::Magenta,
413        Color::Cyan,
414    ];
415    let seed = millis as usize;
416    let version_spans: Vec<Span> = version_text
417        .chars()
418        .enumerate()
419        .map(|(i, c)| {
420            let color = colors[(seed + i) % colors.len()];
421            Span::styled(c.to_string(), Style::default().fg(color))
422        })
423        .collect();
424
425    // When loading, prepend "Loading… <spinner>" in yellow to the version line
426    let right_line = if is_loading {
427        let spin = SPINNER_FRAMES[(millis / 100 % SPINNER_FRAMES.len() as u128) as usize];
428        let mut spans = vec![Span::styled(
429            format!("Loading… {} ", spin),
430            Style::default().fg(Color::Yellow),
431        )];
432        spans.extend(version_spans);
433        Line::from(spans)
434    } else {
435        Line::from(version_spans)
436    };
437
438    // Width: version text + optional "Loading… X " prefix (11 chars)
439    let loading_prefix_len: u16 = if is_loading { 11 } else { 0 };
440    let right_width = version_text.len() as u16 + loading_prefix_len;
441
442    if let Some(line) = status_line_temp {
443        let status_widget = Paragraph::new(line)
444            .alignment(Alignment::Left)
445            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
446        frame.render_widget(mode_widget, chunks[0]);
447        frame.render_widget(spinner_widget, chunks[1]);
448        frame.render_widget(status_widget, chunks[2]);
449    } else {
450        let status_chunks = Layout::default()
451            .direction(Direction::Horizontal)
452            .constraints([Constraint::Min(0), Constraint::Length(right_width)])
453            .split(chunks[2]);
454
455        let help_widget = Paragraph::new(Line::from(help))
456            .block(Block::default())
457            .alignment(Alignment::Left)
458            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
459
460        let version_widget = Paragraph::new(right_line)
461            .alignment(Alignment::Right)
462            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
463
464        frame.render_widget(mode_widget, chunks[0]);
465        frame.render_widget(spinner_widget, chunks[1]);
466        frame.render_widget(help_widget, status_chunks[0]);
467        frame.render_widget(version_widget, status_chunks[1]);
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    #[test]
474    fn test_version_string_format() {
475        let version = env!("CARGO_PKG_VERSION");
476        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
477        let version_text = format!("RUSTICITY v{} (#{})", version, commit);
478
479        assert!(version_text.starts_with("RUSTICITY v"));
480        assert!(version_text.contains("#"));
481    }
482
483    #[test]
484    fn test_version_padding_calculation() {
485        // Simulate help text width
486        let help_text = " ↑↓ scroll ⋮ ←→ toggle ⋮ ⏎ open ⋮ ^o console ⋮ p preferences ⋮ ^p print ⋮ ^r refresh ⋮ ^w close ⋮ q quit ";
487        let help_width: usize = help_text.len();
488
489        let version = env!("CARGO_PKG_VERSION");
490        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
491        let version_text = format!("RUSTICITY v{} (#{})", version, commit);
492        let version_width: usize = version_text.len();
493
494        // Simulate terminal width of 200
495        let status_width: usize = 200;
496        let padding: usize = status_width
497            .saturating_sub(help_width)
498            .saturating_sub(version_width);
499
500        // Total should equal status_width
501        assert_eq!(help_width + padding + version_width, status_width);
502    }
503
504    #[test]
505    fn test_preferences_hint_uses_p_not_ctrl_p() {
506        use super::common_list_hotkeys;
507        let spans = common_list_hotkeys();
508        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
509
510        // Should have "p preferences" not "^p preferences"
511        assert!(
512            text.contains("p preferences"),
513            "Should show 'p preferences'"
514        );
515        assert!(
516            !text.contains("^p preferences"),
517            "Should not show '^p preferences'"
518        );
519    }
520
521    #[test]
522    fn test_print_hint_uses_ctrl_p() {
523        use super::common_list_hotkeys;
524        let spans = common_list_hotkeys();
525        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
526
527        // Should have "^p print" for copy to clipboard
528        assert!(
529            text.contains("^p print"),
530            "Should show '^p print' for copy to clipboard"
531        );
532    }
533
534    #[test]
535    fn test_yank_hint_uses_y() {
536        use super::common_list_hotkeys;
537        let spans = common_list_hotkeys();
538        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
539
540        // Should have "y yank" for copying selected item
541        assert!(
542            text.contains("y yank"),
543            "Should show 'y yank' for copying selected item"
544        );
545    }
546
547    #[test]
548    fn test_common_detail_hotkeys_has_correct_hints() {
549        use super::common_detail_hotkeys;
550        let spans = common_detail_hotkeys();
551        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
552
553        // Detail views should have p for preferences and ^p for print
554        assert!(
555            text.contains("p preferences"),
556            "Detail view should show 'p preferences'"
557        );
558        assert!(
559            text.contains("^p print"),
560            "Detail view should show '^p print'"
561        );
562    }
563
564    #[test]
565    fn test_no_columns_hint_anywhere() {
566        let list_spans = super::common_list_hotkeys();
567        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
568
569        // Should never show "columns", always "preferences"
570        assert!(
571            !list_text.contains("p columns"),
572            "Should not show 'p columns', use 'p preferences' instead"
573        );
574
575        let detail_spans = super::common_detail_hotkeys();
576        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
577        assert!(
578            !detail_text.contains("p columns"),
579            "Should not show 'p columns', use 'p preferences' instead"
580        );
581    }
582
583    #[test]
584    fn test_all_views_have_print_hotkey() {
585        // Test list view
586        let list_spans = super::common_list_hotkeys();
587        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
588        assert!(
589            list_text.contains("^p print"),
590            "List view must have '^p print' hotkey"
591        );
592
593        // Test detail view
594        let detail_spans = super::common_detail_hotkeys();
595        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
596        assert!(
597            detail_text.contains("^p print"),
598            "Detail view must have '^p print' hotkey"
599        );
600    }
601
602    #[test]
603    fn test_preferences_always_uses_p_not_ctrl_p() {
604        let list_spans = super::common_list_hotkeys();
605        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
606        assert!(
607            list_text.contains("p preferences"),
608            "Should use 'p preferences'"
609        );
610        assert!(
611            !list_text.contains("^p preferences"),
612            "Should not use '^p preferences'"
613        );
614
615        let detail_spans = super::common_detail_hotkeys();
616        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
617        assert!(
618            detail_text.contains("p preferences"),
619            "Should use 'p preferences'"
620        );
621        assert!(
622            !detail_text.contains("^p preferences"),
623            "Should not use '^p preferences'"
624        );
625    }
626
627    #[test]
628    fn test_is_loading_true_when_kms_loading() {
629        use crate::app::App;
630        let mut app = App::new_without_client("default".to_string(), None);
631        app.kms_state.keys.loading = true;
632        // Verify is_loading would be true — test by checking the flag directly
633        // (render_bottom_bar is not unit-testable without a frame, but we verify the condition)
634        let is_loading = app.log_groups_state.loading
635            || app.kms_state.keys.loading
636            || app.ecr_state.repositories.loading;
637        assert!(is_loading, "is_loading must be true when kms keys loading");
638    }
639
640    #[test]
641    fn test_is_loading_false_when_nothing_loading() {
642        use crate::app::App;
643        let app = App::new_without_client("default".to_string(), None);
644        let is_loading = app.log_groups_state.loading
645            || app.kms_state.keys.loading
646            || app.ecr_state.repositories.loading
647            || app.ec2_state.table.loading;
648        assert!(
649            !is_loading,
650            "is_loading must be false when nothing is loading"
651        );
652    }
653
654    #[test]
655    fn test_refreshing_message_not_set_on_refresh() {
656        // Ctrl+R must NOT set loading_message to "Refreshing..." —
657        // that message was leaking the spinner into unrelated services.
658        use crate::app::App;
659        use crate::keymap::{Action, Mode};
660        let mut app = App::new_without_client("default".to_string(), None);
661        app.mode = Mode::ProfilePicker;
662        app.handle_action(Action::Refresh);
663        assert!(
664            app.log_groups_state.loading_message.is_empty()
665                || !app.log_groups_state.loading_message.contains("Refreshing"),
666            "Refresh must not set 'Refreshing...' loading_message: got {:?}",
667            app.log_groups_state.loading_message
668        );
669    }
670
671    #[test]
672    fn test_spinner_off_when_all_loading_false() {
673        use crate::app::App;
674        let app = App::new_without_client("default".to_string(), None);
675        // All loading flags default to false
676        let is_loading = app.log_groups_state.loading
677            || app.kms_state.keys.loading
678            || app.ecr_state.repositories.loading
679            || app.ec2_state.table.loading
680            || app.cfn_state.table.loading
681            || app.lambda_state.table.loading
682            || app.lambda_application_state.table.loading
683            || app.alarms_state.table.loading
684            || app.cloudtrail_state.table.loading
685            || app.apig_state.apis.loading
686            || app.sqs_state.queues.loading;
687        assert!(!is_loading, "spinner must be OFF when nothing is loading");
688    }
689
690    #[test]
691    fn test_spinner_on_then_off_lifecycle() {
692        // Simulate: loading=true (spinner on) → load completes → loading=false (spinner off)
693        use crate::app::App;
694        let mut app = App::new_without_client("default".to_string(), None);
695
696        app.kms_state.keys.loading = true;
697        let spinning = app.kms_state.keys.loading;
698        assert!(spinning, "spinner must be ON during load");
699
700        // Simulate load complete
701        app.kms_state.keys.loading = false;
702        let spinning_after = app.kms_state.keys.loading;
703        assert!(!spinning_after, "spinner must be OFF after load completes");
704    }
705
706    #[test]
707    fn test_spinner_on_for_each_service() {
708        use crate::app::App;
709        // Each service loading flag independently triggers the spinner
710        let check = |loading: bool| loading; // mirrors the any_loading OR chain
711
712        assert!(check(true), "loading=true must trigger spinner");
713        assert!(!check(false), "loading=false must not trigger spinner");
714
715        let mut app = App::new_without_client("default".to_string(), None);
716
717        type LoadSetter = Box<dyn Fn(&mut App, bool)>;
718        let cases: Vec<(&str, LoadSetter)> = vec![
719            (
720                "kms",
721                Box::new(|a: &mut App, v| a.kms_state.keys.loading = v),
722            ),
723            (
724                "ecr",
725                Box::new(|a: &mut App, v| a.ecr_state.repositories.loading = v),
726            ),
727            (
728                "ec2",
729                Box::new(|a: &mut App, v| a.ec2_state.table.loading = v),
730            ),
731            (
732                "cfn",
733                Box::new(|a: &mut App, v| a.cfn_state.table.loading = v),
734            ),
735            (
736                "lambda_fn",
737                Box::new(|a: &mut App, v| a.lambda_state.table.loading = v),
738            ),
739            (
740                "alarms",
741                Box::new(|a: &mut App, v| a.alarms_state.table.loading = v),
742            ),
743        ];
744
745        for (name, setter) in cases {
746            setter(&mut app, true);
747            let is_loading = app.kms_state.keys.loading
748                || app.ecr_state.repositories.loading
749                || app.ec2_state.table.loading
750                || app.cfn_state.table.loading
751                || app.lambda_state.table.loading
752                || app.alarms_state.table.loading;
753            assert!(is_loading, "spinner must be ON when {name} is loading");
754            setter(&mut app, false);
755        }
756    }
757
758    #[test]
759    fn test_connection_message_filter_allows_connecting() {
760        // Only "Connecting..." and "Loading log..." messages pass through to left side.
761        let allowed = ["Connecting with new region...", "Loading log groups..."];
762        for msg in &allowed {
763            assert!(
764                msg.starts_with("Connecting") || msg.starts_with("Loading log"),
765                "Message '{msg}' should pass connection filter"
766            );
767        }
768        let blocked = ["Refreshing...", "Loading KMS keys...", ""];
769        for msg in &blocked {
770            assert!(
771                !(msg.starts_with("Connecting") || msg.starts_with("Loading log")),
772                "Message '{msg}' should be blocked by connection filter"
773            );
774        }
775    }
776}