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 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 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 if app.cfn_state.detail_tab == DetailTab::Template
325 || app.cfn_state.detail_tab == DetailTab::GitSync
326 {
327 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 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 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 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 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 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 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 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 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 let status_width: usize = 200;
599 let padding: usize = status_width
600 .saturating_sub(help_width)
601 .saturating_sub(version_width);
602
603 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 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 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 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 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 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 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 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 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 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 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 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 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 let check = |loading: bool| loading; 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 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}