Skip to main content

rusticity_term/ui/
status.rs

1use crate::app::{App, Service, ViewMode};
2use crate::dynamodb::{actions::is_items_tab, items::can_load_more};
3use crate::finder::Focus as FinderFocus;
4use crate::keymap::Mode;
5use crate::ui::cfn::DetailTab;
6use crate::ui::red_text;
7use ratatui::{prelude::*, widgets::*};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
11
12pub fn first_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
13    vec![
14        Span::styled(key, red_text()),
15        Span::raw(" "),
16        Span::raw(action),
17        Span::raw(" ⋮"),
18    ]
19}
20
21pub fn hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
22    vec![
23        Span::raw(" "),
24        Span::styled(key, red_text()),
25        Span::raw(" "),
26        Span::raw(action),
27        Span::raw(" ⋮"),
28    ]
29}
30
31pub fn last_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
32    vec![
33        Span::raw(" "),
34        Span::styled(key, red_text()),
35        Span::raw(" "),
36        Span::raw(action),
37    ]
38}
39
40fn common_detail_hotkeys() -> Vec<Span<'static>> {
41    let mut spans = vec![];
42    spans.extend(first_hint("↑↓", "scroll"));
43    spans.extend(hint("⎋", "back"));
44    spans.extend(hint("[]", "switch"));
45    spans.extend(hint("⇤⇥", "switch"));
46    spans.extend(hint("^u", "page up"));
47    spans.extend(hint("^d", "page down"));
48    spans.extend(hint("y", "yank"));
49    spans.extend(hint("^o", "console"));
50    spans.extend(hint("p", "preferences"));
51    spans.extend(hint("^p", "print"));
52    spans.extend(hint("^r", "refresh"));
53    spans.extend(hint("^w", "close"));
54    spans.extend(last_hint("q", "quit"));
55    spans
56}
57
58fn common_list_hotkeys() -> Vec<Span<'static>> {
59    let mut spans = vec![];
60    spans.extend(first_hint("↑↓", "scroll"));
61    spans.extend(hint("←→", "toggle"));
62    spans.extend(hint("⏎", "open"));
63    spans.extend(hint("[]", "switch"));
64    spans.extend(hint("^u", "page up"));
65    spans.extend(hint("^d", "page down"));
66    spans.extend(hint("y", "yank"));
67    spans.extend(hint("^o", "console"));
68    spans.extend(hint("p", "preferences"));
69    spans.extend(hint("^p", "print"));
70    spans.extend(hint("^r", "refresh"));
71    spans.extend(hint("^w", "close"));
72    spans.extend(last_hint("q", "quit"));
73    spans
74}
75
76fn dynamodb_items_hotkeys(app: &App) -> Vec<Span<'static>> {
77    let mut hints = vec![];
78    if can_load_more(app) {
79        // Put this first so it is visible even when the remaining hints are clipped.
80        let action = if app.dynamodb_state.items_failed {
81            "retry load more"
82        } else {
83            "load more"
84        };
85        hints.extend(first_hint("m", action));
86        hints.push(Span::raw(" "));
87    }
88    hints.extend(common_detail_hotkeys());
89    hints
90}
91
92pub fn render_bottom_bar(frame: &mut Frame, app: &App, area: Rect) {
93    let is_insert = match app.mode {
94        Mode::FilterInput | Mode::EventFilterInput | Mode::InsightsInput => true,
95        Mode::ResourceFinder => app.resource_finder.focus == FinderFocus::Filter,
96        Mode::ServicePicker | Mode::SpaceMenu => app.service_picker.filter_active,
97        Mode::RegionPicker => app.region_filter_active,
98        Mode::SessionPicker => app.session_filter_active,
99        Mode::ProfilePicker => app.profile_filter_active,
100        _ => false,
101    };
102
103    let mode_indicator = if is_insert { " INSERT " } else { " NORMAL " };
104    let mode_style = if is_insert {
105        Style::default().bg(Color::Yellow).fg(Color::Black)
106    } else {
107        Style::default().bg(Color::Blue).fg(Color::White)
108    };
109
110    let mut help = if app.mode == Mode::Normal && crate::s3::object::active(app) {
111        let mut hints = first_hint("↑↓", "scroll");
112        if app.s3_state.object_view.preview.is_some() {
113            hints.extend(hint("^u/^d", "page"));
114            hints.extend(hint("y", "copy text"));
115        } else {
116            hints.extend(hint("⏎", "open / preview"));
117            hints.extend(hint("^d", "download"));
118            hints.extend(hint("i", "filter"));
119        }
120        hints.extend(hint("⎋", "back / cancel"));
121        hints.extend(last_hint("q", "quit"));
122        hints
123    } else if app.mode == Mode::ResourceFinder {
124        let mut hints = if app.resource_finder.focus == FinderFocus::Filter {
125            first_hint("⏎", "results")
126        } else {
127            let mut hints = first_hint("↑↓", "select");
128            hints.extend(hint("⏎", "open"));
129            hints.extend(hint("i", "filter"));
130            hints
131        };
132        if app.resource_finder.related_source.is_some()
133            && app.resource_finder.focus == FinderFocus::Table
134        {
135            hints.extend(hint("y", "copy reference"));
136        }
137        hints.extend(hint("⇤⇥", "switch"));
138        hints.extend(hint("^r", "refresh"));
139        hints.extend(last_hint("⎋", "cancel"));
140        hints
141    } else if app.mode == Mode::QuitConfirm {
142        let mut hints = vec![];
143        hints.extend(first_hint("y", "confirm quit"));
144        hints.extend(last_hint("n / Esc", "cancel"));
145        hints
146    } else if app.mode == Mode::ColumnSelector {
147        let mut hints = vec![];
148        hints.extend(hint("↑↓", "scroll"));
149        hints.extend(hint("␣", "toggle"));
150
151        if app.current_service == Service::CloudWatchAlarms {
152            hints.extend(hint("⇤⇥", "switch"));
153        }
154
155        hints.extend(last_hint("⎋", "close"));
156        hints
157    } else if app.mode == Mode::InsightsInput {
158        let mut hints = vec![];
159        hints.extend(hint(" tab", "switch"));
160        hints.extend(hint("↑↓", "scroll"));
161        hints.extend(hint("␣", "toggle"));
162        hints.extend(hint("⏎", "execute"));
163        hints.extend(hint("⎋", "cancel"));
164        hints.extend(hint("^r", "refresh"));
165        hints.extend(last_hint("^w", "close"));
166        hints
167    } else if app.current_service == Service::CloudWatchInsights {
168        let mut hints = vec![];
169        hints.extend(hint(" i", "insert"));
170        hints.extend(hint("⏎", "execute"));
171        hints.extend(hint("?", "help"));
172        hints.extend(hint("^r", "refresh"));
173        hints.extend(hint("^o", "console"));
174        hints.extend(hint("⎋", "back"));
175        hints.extend(hint("^w", "close"));
176        hints.extend(last_hint("q", "quit"));
177        hints
178    } else if app.mode == Mode::EventFilterInput {
179        let mut hints = vec![];
180        hints.extend(first_hint("⇤⇥", "switch"));
181        hints.extend(hint("␣", "change unit"));
182        hints.extend(hint("⏎", "apply"));
183        hints.extend(hint("⎋", "cancel"));
184        hints.extend(last_hint("^w", "close"));
185        hints
186    } else if app.mode == Mode::RegionPicker {
187        let mut hints = vec![];
188        hints.extend(first_hint("↑↓", "scroll"));
189        hints.extend(hint("i", "filter"));
190        hints.extend(hint("⏎", "select"));
191        hints.extend(hint("^l", "measure latency"));
192        hints.extend(hint("⎋", "close"));
193        hints.extend(last_hint("q", "quit"));
194        hints
195    } else if app.mode == Mode::FilterInput {
196        let mut hints = vec![];
197        hints.extend(first_hint("⏎", "apply"));
198        hints.extend(hint("⎋", "cancel"));
199        hints.extend(hint("␣", "toggle"));
200        hints.extend(hint("⇤⇥", "switch"));
201        hints.extend(last_hint("^w", "close"));
202        hints
203    } else if app.mode == Mode::Normal
204        && app.service_selected
205        && app.current_service == Service::DynamoDbTables
206        && is_items_tab(app)
207    {
208        // Service-specific Items hints must precede the legacy ViewMode branches.
209        dynamodb_items_hotkeys(app)
210    } else if app.mode == Mode::Normal
211        && app.service_selected
212        && matches!(
213            app.current_service,
214            Service::RdsInstances | Service::VpcVpcs
215        )
216    {
217        let mut hints = first_hint("↑↓", "scroll");
218        hints.extend(hint("⏎ / ←→", "toggle"));
219        hints.extend(hint("i", "filter"));
220        hints.extend(hint("p", "preferences"));
221        hints.extend(hint("^p", "print"));
222        hints.extend(hint("y", "copy ARN"));
223        hints.extend(hint("^o", "console"));
224        hints.extend(hint("^r", "refresh"));
225        hints.extend(hint("^w", "close"));
226        hints.extend(last_hint("q", "quit"));
227        hints
228    } else if app.view_mode == ViewMode::Events {
229        let mut hints = vec![];
230        hints.extend(first_hint("↑↓", "scroll"));
231        hints.extend(hint("←→", "toggle"));
232        hints.extend(hint("y", "yank"));
233        hints.extend(hint("^o", "console"));
234        hints.extend(hint("^r", "refresh"));
235        hints.extend(hint("p", "preferences"));
236        hints.extend(hint("^p", "print"));
237        hints.extend(hint("^w", "close"));
238        hints.extend(last_hint("q", "quit"));
239        hints
240    } else if app.current_service == Service::CloudWatchAlarms
241        && app.alarms_state.current_alarm.is_some()
242    {
243        let mut hints = vec![];
244        hints.extend(first_hint("↑↓", "scroll"));
245        hints.extend(hint("⇤⇥", "switch tab"));
246        hints.extend(hint("⎋", "back"));
247        hints.extend(hint("y", "copy JSON"));
248        hints.extend(hint("^o", "console"));
249        hints.extend(hint("^r", "refresh"));
250        hints.extend(hint("^w", "close"));
251        hints.extend(last_hint("q", "quit"));
252        hints
253    } else if app.view_mode == ViewMode::Detail {
254        let mut hints = vec![];
255        hints.extend(first_hint("↑↓", "scroll"));
256        hints.extend(hint("←→", "toggle"));
257        hints.extend(hint("⏎", "open"));
258        hints.extend(hint("⎋", "back"));
259        hints.extend(hint("i", "insert"));
260        hints.extend(hint("s", "sort"));
261        hints.extend(hint("o", "order"));
262        hints.extend(hint("<num>p", "page"));
263        hints.extend(hint("^o", "console"));
264        hints.extend(hint("⇤⇥", "switch"));
265        hints.extend(hint("^r", "refresh"));
266        hints.extend(hint("p", "preferences"));
267        hints.extend(hint("^p", "print"));
268        hints.extend(hint("^w", "close"));
269        hints.extend(last_hint("q", "quit"));
270        hints
271    } else if app.current_service == Service::EcrRepositories {
272        if app.ecr_state.current_repository.is_some() {
273            let mut hints = vec![];
274            hints.extend(first_hint("↑↓", "scroll"));
275            hints.extend(hint("←→", "toggle"));
276            hints.extend(hint("⎋", "back"));
277            hints.extend(hint("y", "yank"));
278            hints.extend(hint("^o", "console"));
279            hints.extend(hint("^r", "refresh"));
280            hints.extend(hint("^w", "close"));
281            hints.extend(last_hint("q", "quit"));
282            hints
283        } else {
284            let mut hints = vec![];
285            hints.extend(first_hint("↑↓", "scroll"));
286            hints.extend(hint("←→", "toggle"));
287            hints.extend(hint("⏎", "open"));
288            hints.extend(hint("⇤⇥", "switch"));
289            hints.extend(hint("y", "yank"));
290            hints.extend(hint("^o", "console"));
291            hints.extend(hint("^r", "refresh"));
292            hints.extend(hint("^w", "close"));
293            hints.extend(last_hint("q", "quit"));
294            hints
295        }
296    } else if app.current_service == Service::S3Buckets {
297        if app.s3_state.current_bucket.is_some() {
298            let mut hints = vec![];
299            hints.extend(first_hint("↑↓", "scroll"));
300            hints.extend(hint("←→", "toggle"));
301            hints.extend(hint("⏎", "open"));
302            hints.extend(hint("⎋", "back"));
303            hints.extend(hint("⇤⇥", "switch"));
304            hints.extend(hint("^o", "console"));
305            hints.extend(hint("^r", "refresh"));
306            hints.extend(hint("^w", "close"));
307            hints.extend(last_hint("q", "quit"));
308            hints
309        } else {
310            let mut hints = vec![];
311            hints.extend(first_hint("↑↓", "scroll"));
312            hints.extend(hint("←→", "toggle"));
313            hints.extend(hint("⏎", "open"));
314            hints.extend(hint("⇤⇥", "switch"));
315            hints.extend(hint("^o", "console"));
316            hints.extend(hint("^r", "refresh"));
317            hints.extend(hint("^w", "close"));
318            hints.extend(last_hint("q", "quit"));
319            hints
320        }
321    } else if app.current_service == Service::CloudFormationStacks {
322        if app.cfn_state.current_stack.is_some() {
323            // In stack detail view - customize hints based on tab
324            if app.cfn_state.detail_tab == DetailTab::Template
325                || app.cfn_state.detail_tab == DetailTab::GitSync
326            {
327                // Template and GitSync tabs: no preferences
328                let mut hints = vec![];
329                hints.extend(first_hint("↑↓", "scroll"));
330                hints.extend(hint("⎋", "back"));
331                hints.extend(hint("[]", "switch"));
332                hints.extend(hint("⇤⇥", "switch"));
333                hints.extend(hint("^u", "page up"));
334                hints.extend(hint("^d", "page down"));
335                hints.extend(hint("y", "yank"));
336                hints.extend(hint("^o", "console"));
337                hints.extend(hint("^r", "refresh"));
338                hints.extend(hint("^w", "close"));
339                hints.extend(last_hint("q", "quit"));
340                hints
341            } else {
342                common_detail_hotkeys()
343            }
344        } else {
345            // In stack list view - build custom hints with snapshot
346            let mut hints = vec![];
347            hints.extend(first_hint("↑↓", "scroll"));
348            hints.extend(hint("←→", "toggle"));
349            hints.extend(hint("⏎", "open"));
350            hints.extend(hint("[]", "switch"));
351            hints.extend(hint("^u", "page up"));
352            hints.extend(hint("^d", "page down"));
353            hints.extend(hint("y", "yank"));
354            hints.extend(hint("^p", "snapshot"));
355            hints.extend(hint("^o", "console"));
356            hints.extend(hint("^r", "refresh"));
357            hints.extend(hint("^w", "close"));
358            hints.extend(last_hint("q", "quit"));
359            hints
360        }
361    } else if app.current_service == Service::IamUsers {
362        if app.iam_state.current_user.is_some() {
363            common_detail_hotkeys()
364        } else {
365            common_list_hotkeys()
366        }
367    } else if app.current_service == Service::IamRoles {
368        if app.iam_state.current_role.is_some() {
369            common_detail_hotkeys()
370        } else {
371            common_list_hotkeys()
372        }
373    } else if app.current_service == Service::LambdaFunctions {
374        if app.lambda_state.current_function.is_some() {
375            common_detail_hotkeys()
376        } else {
377            common_list_hotkeys()
378        }
379    } else if app.view_mode == ViewMode::List {
380        let mut hints = vec![];
381        hints.extend(first_hint("↑↓", "scroll"));
382        hints.extend(hint("←→", "toggle"));
383        hints.extend(hint("⏎", "open"));
384        hints.extend(hint("^o", "console"));
385        hints.extend(hint("p", "preferences"));
386        hints.extend(hint("^p", "print"));
387        hints.extend(hint("^r", "refresh"));
388        hints.extend(hint("^w", "close"));
389        hints.extend(last_hint("q", "quit"));
390        hints
391    } else {
392        let mut hints = vec![];
393        hints.extend(first_hint("↑↓", "scroll"));
394        hints.extend(hint("←→", "toggle"));
395        hints.extend(hint("⏎", "open"));
396        hints.extend(hint("⎋", "back"));
397        hints.extend(hint("<num>p", "page"));
398        hints.extend(hint("^o", "console"));
399        hints.extend(hint("^r", "refresh"));
400        hints.extend(hint("p", "preferences"));
401        hints.extend(hint("^p", "print"));
402        hints.extend(hint("^w", "close"));
403        hints.extend(last_hint("q", "quit"));
404        hints
405    };
406    if app.mode == Mode::Normal && crate::finder::State::related_available(app) {
407        let mut related = first_hint("^l", "related");
408        related.push(Span::raw(" "));
409        related.extend(help);
410        help = related;
411    }
412
413    let millis = SystemTime::now()
414        .duration_since(UNIX_EPOCH)
415        .unwrap()
416        .as_millis();
417
418    // Any service loading → show generic spinner inline with version on the right
419    let is_loading = app.s3_state.object_view.busy()
420        || app.log_groups_state.loading
421        || app.vpc_state.vpcs.loading
422        || app.rds_state.instances.loading
423        || app.kms_state.keys.loading
424        || app.ecr_state.repositories.loading
425        || app.ec2_state.table.loading
426        || app.cfn_state.table.loading
427        || app.lambda_state.table.loading
428        || app.lambda_application_state.table.loading
429        || app.alarms_state.table.loading
430        || app.cloudtrail_state.table.loading
431        || app.apig_state.apis.loading
432        || app.efs_state.file_systems.loading
433        || app.sqs_state.queues.loading;
434
435    let spinner_frame = if is_loading {
436        SPINNER_FRAMES[(millis / 100 % SPINNER_FRAMES.len() as u128) as usize]
437    } else {
438        " "
439    };
440
441    // Only show a left-side connection message (profile/region change), not generic loading noise
442    let connection_message: Option<String> =
443        if app.log_groups_state.loading && !app.log_groups_state.loading_message.is_empty() {
444            let msg = &app.log_groups_state.loading_message;
445            // Only connection messages, not "Refreshing..." noise
446            if msg.starts_with("Connecting") || msg.starts_with("Loading log") {
447                let max_width = area.width.saturating_sub(10) as usize;
448                Some(if msg.len() > max_width {
449                    format!("{}...", &msg[..max_width.saturating_sub(3)])
450                } else {
451                    msg.clone()
452                })
453            } else {
454                None
455            }
456        } else {
457            None
458        };
459
460    let status_line_temp = if let Some(msg) = connection_message {
461        Some(Line::from(vec![Span::raw(msg)]))
462    } else if !app.page_input.is_empty() {
463        Some(Line::from(vec![Span::raw(format!(
464            "Go to page {} (press p to confirm)",
465            app.page_input
466        ))]))
467    } else if crate::s3::object::active(app) && app.s3_state.object_view.notice.is_some() {
468        let notice = app.s3_state.object_view.notice.as_ref().unwrap();
469        Some(Line::styled(
470            notice.message.clone(),
471            Style::default().fg(if notice.failed {
472                Color::Red
473            } else {
474                Color::Green
475            }),
476        ))
477    } else {
478        app.clipboard.notice().map(|notice| {
479            Line::styled(
480                notice.message.clone(),
481                Style::default().fg(if notice.failed {
482                    Color::Red
483                } else {
484                    Color::Green
485                }),
486            )
487        })
488    };
489
490    let chunks = Layout::default()
491        .direction(Direction::Horizontal)
492        .constraints([
493            Constraint::Length(8),
494            Constraint::Length(2),
495            Constraint::Min(0),
496        ])
497        .split(area);
498
499    let mode_widget = Paragraph::new(mode_indicator).style(mode_style);
500
501    let spinner_widget = Paragraph::new(spinner_frame)
502        .block(Block::default())
503        .style(Style::default().bg(Color::DarkGray).fg(Color::Yellow));
504
505    // Build version string
506    let version = env!("CARGO_PKG_VERSION");
507    let commit = option_env!("GIT_HASH").unwrap_or("unknown");
508    let version_text = format!("⋮ RUSTICITY v{} (#{})", version, commit);
509
510    let colors = [
511        Color::Red,
512        Color::Green,
513        Color::Yellow,
514        Color::Blue,
515        Color::Magenta,
516        Color::Cyan,
517    ];
518    let seed = millis as usize;
519    let version_spans: Vec<Span> = version_text
520        .chars()
521        .enumerate()
522        .map(|(i, c)| {
523            let color = colors[(seed + i) % colors.len()];
524            Span::styled(c.to_string(), Style::default().fg(color))
525        })
526        .collect();
527
528    // When loading, prepend "Loading… <spinner>" in yellow to the version line
529    let right_line = if is_loading {
530        let spin = SPINNER_FRAMES[(millis / 100 % SPINNER_FRAMES.len() as u128) as usize];
531        let mut spans = vec![Span::styled(
532            format!("Loading… {} ", spin),
533            Style::default().fg(Color::Yellow),
534        )];
535        spans.extend(version_spans);
536        Line::from(spans)
537    } else {
538        Line::from(version_spans)
539    };
540
541    // Width: version text + optional "Loading… X " prefix (11 chars)
542    let loading_prefix_len: u16 = if is_loading { 11 } else { 0 };
543    let right_width = version_text.len() as u16 + loading_prefix_len;
544
545    if let Some(line) = status_line_temp {
546        let status_widget = Paragraph::new(line)
547            .alignment(Alignment::Left)
548            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
549        frame.render_widget(mode_widget, chunks[0]);
550        frame.render_widget(spinner_widget, chunks[1]);
551        frame.render_widget(status_widget, chunks[2]);
552    } else {
553        let status_chunks = Layout::default()
554            .direction(Direction::Horizontal)
555            .constraints([Constraint::Min(0), Constraint::Length(right_width)])
556            .split(chunks[2]);
557
558        let help_widget = Paragraph::new(Line::from(help))
559            .block(Block::default())
560            .alignment(Alignment::Left)
561            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
562
563        let version_widget = Paragraph::new(right_line)
564            .alignment(Alignment::Right)
565            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
566
567        frame.render_widget(mode_widget, chunks[0]);
568        frame.render_widget(spinner_widget, chunks[1]);
569        frame.render_widget(help_widget, status_chunks[0]);
570        frame.render_widget(version_widget, status_chunks[1]);
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    #[test]
577    fn test_version_string_format() {
578        let version = env!("CARGO_PKG_VERSION");
579        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
580        let version_text = format!("RUSTICITY v{} (#{})", version, commit);
581
582        assert!(version_text.starts_with("RUSTICITY v"));
583        assert!(version_text.contains("#"));
584    }
585
586    #[test]
587    fn test_version_padding_calculation() {
588        // Simulate help text width
589        let help_text = " ↑↓ scroll ⋮ ←→ toggle ⋮ ⏎ open ⋮ ^o console ⋮ p preferences ⋮ ^p print ⋮ ^r refresh ⋮ ^w close ⋮ q quit ";
590        let help_width: usize = help_text.len();
591
592        let version = env!("CARGO_PKG_VERSION");
593        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
594        let version_text = format!("RUSTICITY v{} (#{})", version, commit);
595        let version_width: usize = version_text.len();
596
597        // Simulate terminal width of 200
598        let status_width: usize = 200;
599        let padding: usize = status_width
600            .saturating_sub(help_width)
601            .saturating_sub(version_width);
602
603        // Total should equal status_width
604        assert_eq!(help_width + padding + version_width, status_width);
605    }
606
607    #[test]
608    fn test_preferences_hint_uses_p_not_ctrl_p() {
609        use super::common_list_hotkeys;
610        let spans = common_list_hotkeys();
611        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
612
613        // Should have "p preferences" not "^p preferences"
614        assert!(
615            text.contains("p preferences"),
616            "Should show 'p preferences'"
617        );
618        assert!(
619            !text.contains("^p preferences"),
620            "Should not show '^p preferences'"
621        );
622    }
623
624    #[test]
625    fn test_print_hint_uses_ctrl_p() {
626        use super::common_list_hotkeys;
627        let spans = common_list_hotkeys();
628        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
629
630        // Should have "^p print" for copy to clipboard
631        assert!(
632            text.contains("^p print"),
633            "Should show '^p print' for copy to clipboard"
634        );
635    }
636
637    #[test]
638    fn test_yank_hint_uses_y() {
639        use super::common_list_hotkeys;
640        let spans = common_list_hotkeys();
641        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
642
643        // Should have "y yank" for copying selected item
644        assert!(
645            text.contains("y yank"),
646            "Should show 'y yank' for copying selected item"
647        );
648    }
649
650    #[test]
651    fn test_common_detail_hotkeys_has_correct_hints() {
652        use super::common_detail_hotkeys;
653        let spans = common_detail_hotkeys();
654        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
655
656        // Detail views should have p for preferences and ^p for print
657        assert!(
658            text.contains("p preferences"),
659            "Detail view should show 'p preferences'"
660        );
661        assert!(
662            text.contains("^p print"),
663            "Detail view should show '^p print'"
664        );
665    }
666
667    #[test]
668    fn test_no_columns_hint_anywhere() {
669        let list_spans = super::common_list_hotkeys();
670        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
671
672        // Should never show "columns", always "preferences"
673        assert!(
674            !list_text.contains("p columns"),
675            "Should not show 'p columns', use 'p preferences' instead"
676        );
677
678        let detail_spans = super::common_detail_hotkeys();
679        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
680        assert!(
681            !detail_text.contains("p columns"),
682            "Should not show 'p columns', use 'p preferences' instead"
683        );
684    }
685
686    #[test]
687    fn test_all_views_have_print_hotkey() {
688        // Test list view
689        let list_spans = super::common_list_hotkeys();
690        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
691        assert!(
692            list_text.contains("^p print"),
693            "List view must have '^p print' hotkey"
694        );
695
696        // Test detail view
697        let detail_spans = super::common_detail_hotkeys();
698        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
699        assert!(
700            detail_text.contains("^p print"),
701            "Detail view must have '^p print' hotkey"
702        );
703    }
704
705    #[test]
706    fn test_preferences_always_uses_p_not_ctrl_p() {
707        let list_spans = super::common_list_hotkeys();
708        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
709        assert!(
710            list_text.contains("p preferences"),
711            "Should use 'p preferences'"
712        );
713        assert!(
714            !list_text.contains("^p preferences"),
715            "Should not use '^p preferences'"
716        );
717
718        let detail_spans = super::common_detail_hotkeys();
719        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
720        assert!(
721            detail_text.contains("p preferences"),
722            "Should use 'p preferences'"
723        );
724        assert!(
725            !detail_text.contains("^p preferences"),
726            "Should not use '^p preferences'"
727        );
728    }
729
730    #[test]
731    fn test_is_loading_true_when_kms_loading() {
732        use crate::app::App;
733        let mut app = App::new_without_client("default".to_string(), None);
734        app.kms_state.keys.loading = true;
735        // Verify is_loading would be true — test by checking the flag directly
736        // (render_bottom_bar is not unit-testable without a frame, but we verify the condition)
737        let is_loading = app.log_groups_state.loading
738            || app.kms_state.keys.loading
739            || app.ecr_state.repositories.loading;
740        assert!(is_loading, "is_loading must be true when kms keys loading");
741    }
742
743    #[test]
744    fn test_is_loading_false_when_nothing_loading() {
745        use crate::app::App;
746        let app = App::new_without_client("default".to_string(), None);
747        let is_loading = app.log_groups_state.loading
748            || app.kms_state.keys.loading
749            || app.ecr_state.repositories.loading
750            || app.ec2_state.table.loading;
751        assert!(
752            !is_loading,
753            "is_loading must be false when nothing is loading"
754        );
755    }
756
757    #[test]
758    fn test_refreshing_message_not_set_on_refresh() {
759        // Ctrl+R must NOT set loading_message to "Refreshing..." —
760        // that message was leaking the spinner into unrelated services.
761        use crate::app::App;
762        use crate::keymap::{Action, Mode};
763        let mut app = App::new_without_client("default".to_string(), None);
764        app.mode = Mode::ProfilePicker;
765        app.handle_action(Action::Refresh);
766        assert!(
767            app.log_groups_state.loading_message.is_empty()
768                || !app.log_groups_state.loading_message.contains("Refreshing"),
769            "Refresh must not set 'Refreshing...' loading_message: got {:?}",
770            app.log_groups_state.loading_message
771        );
772    }
773
774    #[test]
775    fn test_spinner_off_when_all_loading_false() {
776        use crate::app::App;
777        let app = App::new_without_client("default".to_string(), None);
778        // All loading flags default to false
779        let is_loading = app.log_groups_state.loading
780            || app.kms_state.keys.loading
781            || app.ecr_state.repositories.loading
782            || app.ec2_state.table.loading
783            || app.cfn_state.table.loading
784            || app.lambda_state.table.loading
785            || app.lambda_application_state.table.loading
786            || app.alarms_state.table.loading
787            || app.cloudtrail_state.table.loading
788            || app.apig_state.apis.loading
789            || app.sqs_state.queues.loading;
790        assert!(!is_loading, "spinner must be OFF when nothing is loading");
791    }
792
793    #[test]
794    fn test_spinner_on_then_off_lifecycle() {
795        // Simulate: loading=true (spinner on) → load completes → loading=false (spinner off)
796        use crate::app::App;
797        let mut app = App::new_without_client("default".to_string(), None);
798
799        app.kms_state.keys.loading = true;
800        let spinning = app.kms_state.keys.loading;
801        assert!(spinning, "spinner must be ON during load");
802
803        // Simulate load complete
804        app.kms_state.keys.loading = false;
805        let spinning_after = app.kms_state.keys.loading;
806        assert!(!spinning_after, "spinner must be OFF after load completes");
807    }
808
809    #[test]
810    fn test_spinner_on_for_each_service() {
811        use crate::app::App;
812        // Each service loading flag independently triggers the spinner
813        let check = |loading: bool| loading; // mirrors the any_loading OR chain
814
815        assert!(check(true), "loading=true must trigger spinner");
816        assert!(!check(false), "loading=false must not trigger spinner");
817
818        let mut app = App::new_without_client("default".to_string(), None);
819
820        type LoadSetter = Box<dyn Fn(&mut App, bool)>;
821        let cases: Vec<(&str, LoadSetter)> = vec![
822            (
823                "kms",
824                Box::new(|a: &mut App, v| a.kms_state.keys.loading = v),
825            ),
826            (
827                "ecr",
828                Box::new(|a: &mut App, v| a.ecr_state.repositories.loading = v),
829            ),
830            (
831                "ec2",
832                Box::new(|a: &mut App, v| a.ec2_state.table.loading = v),
833            ),
834            (
835                "cfn",
836                Box::new(|a: &mut App, v| a.cfn_state.table.loading = v),
837            ),
838            (
839                "lambda_fn",
840                Box::new(|a: &mut App, v| a.lambda_state.table.loading = v),
841            ),
842            (
843                "alarms",
844                Box::new(|a: &mut App, v| a.alarms_state.table.loading = v),
845            ),
846        ];
847
848        for (name, setter) in cases {
849            setter(&mut app, true);
850            let is_loading = app.kms_state.keys.loading
851                || app.ecr_state.repositories.loading
852                || app.ec2_state.table.loading
853                || app.cfn_state.table.loading
854                || app.lambda_state.table.loading
855                || app.alarms_state.table.loading;
856            assert!(is_loading, "spinner must be ON when {name} is loading");
857            setter(&mut app, false);
858        }
859    }
860
861    #[test]
862    fn test_connection_message_filter_allows_connecting() {
863        // Only "Connecting..." and "Loading log..." messages pass through to left side.
864        let allowed = ["Connecting with new region...", "Loading log groups..."];
865        for msg in &allowed {
866            assert!(
867                msg.starts_with("Connecting") || msg.starts_with("Loading log"),
868                "Message '{msg}' should pass connection filter"
869            );
870        }
871        let blocked = ["Refreshing...", "Loading KMS keys...", ""];
872        for msg in &blocked {
873            assert!(
874                !(msg.starts_with("Connecting") || msg.starts_with("Loading log")),
875                "Message '{msg}' should be blocked by connection filter"
876            );
877        }
878    }
879}
880
881#[cfg(test)]
882mod dynamodb_hint_tests {
883    use super::*;
884    use crate::dynamodb::items::context;
885    use crate::ui::dynamodb::DetailTab as DdbDetailTab;
886    use aws_sdk_dynamodb::types::AttributeValue;
887    use ratatui::{backend::TestBackend, Terminal};
888    use rusticity_core::dynamodb::DdbTable;
889
890    fn items_app() -> App {
891        let mut app = App::new_without_client("test".into(), Some("us-east-2".into()));
892        app.current_service = Service::DynamoDbTables;
893        app.service_selected = true;
894        app.mode = Mode::Normal;
895        app.dynamodb_state.current_table = Some("orders".into());
896        app.dynamodb_state.detail_tab = DdbDetailTab::Items;
897        app.dynamodb_state.tables.items = vec![DdbTable {
898            name: "orders".into(),
899            partition_key: "pk (S)".into(),
900            ..Default::default()
901        }];
902        app.dynamodb_state.items_context = context(&app);
903        app.dynamodb_state.items_cursor = Some(
904            [("pk".into(), AttributeValue::S("next".into()))]
905                .into_iter()
906                .collect(),
907        );
908        app
909    }
910
911    fn status_text(app: &App, width: u16) -> String {
912        let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap();
913        terminal
914            .draw(|f| render_bottom_bar(f, app, f.area()))
915            .unwrap();
916        terminal
917            .backend()
918            .buffer()
919            .content()
920            .iter()
921            .map(|c| c.symbol())
922            .collect()
923    }
924
925    #[test]
926    fn items_load_more_hint_precedes_generic_view_hints() {
927        let mut app = items_app();
928        for mode in [ViewMode::List, ViewMode::Detail, ViewMode::Events] {
929            app.view_mode = mode;
930            let text = status_text(&app, 300);
931            assert!(
932                text.contains("m load more"),
933                "Missing hint with {mode:?}: {text}"
934            );
935            assert!(text.contains("p preferences"));
936            assert!(text.contains("^r refresh"));
937        }
938    }
939
940    #[test]
941    fn items_load_more_hint_is_visible_in_an_eighty_column_terminal() {
942        assert!(status_text(&items_app(), 80).contains("m load more"));
943    }
944
945    #[test]
946    fn items_load_more_hint_only_advertises_an_available_action() {
947        let cases: &[fn(&mut App)] = &[
948            |a| a.current_service = Service::EcrRepositories,
949            |a| a.service_selected = false,
950            |a| a.dynamodb_state.current_table = None,
951            |a| a.dynamodb_state.detail_tab = DdbDetailTab::Settings,
952            |a| a.dynamodb_state.items_cursor = None,
953            |a| a.dynamodb_state.items_context = None,
954            |a| a.dynamodb_state.items_loading = true,
955            |a| a.profile = "another-profile".into(),
956        ];
957        for configure in cases {
958            let mut app = items_app();
959            configure(&mut app);
960            assert!(!status_text(&app, 300).contains("load more"));
961        }
962        for mode in [
963            Mode::FilterInput,
964            Mode::ColumnSelector,
965            Mode::ErrorModal,
966            Mode::HelpModal,
967            Mode::QuitConfirm,
968            Mode::ProfilePicker,
969            Mode::RegionPicker,
970            Mode::CalendarPicker,
971            Mode::TabPicker,
972            Mode::SessionPicker,
973            Mode::SpaceMenu,
974            Mode::EventFilterInput,
975            Mode::InsightsInput,
976        ] {
977            let mut app = items_app();
978            app.mode = mode;
979            assert!(
980                !status_text(&app, 300).contains("load more"),
981                "Unexpected hint in {mode:?}"
982            );
983        }
984    }
985
986    #[test]
987    fn items_failed_continuation_shows_retry_hint_only_with_a_valid_cursor() {
988        let mut app = items_app();
989        app.dynamodb_state.items_failed = true;
990        assert!(status_text(&app, 80).contains("m retry load more"));
991        app.dynamodb_state.items_cursor = None;
992        assert!(!status_text(&app, 300).contains("load more"));
993    }
994}