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 if app.cfn_state.detail_tab == DetailTab::Template
250 || app.cfn_state.detail_tab == DetailTab::GitSync
251 {
252 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 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 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 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 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 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 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 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 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 let status_width: usize = 200;
496 let padding: usize = status_width
497 .saturating_sub(help_width)
498 .saturating_sub(version_width);
499
500 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 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 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 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 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 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 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 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 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 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 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 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 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 let check = |loading: bool| loading; 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 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}