use crate::app::{App, Service, ViewMode};
use crate::dynamodb::{actions::is_items_tab, items::can_load_more};
use crate::finder::Focus as FinderFocus;
use crate::keymap::Mode;
use crate::ui::cfn::DetailTab;
use crate::ui::red_text;
use ratatui::{prelude::*, widgets::*};
use std::time::{SystemTime, UNIX_EPOCH};
pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
pub fn first_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
vec![
Span::styled(key, red_text()),
Span::raw(" "),
Span::raw(action),
Span::raw(" ⋮"),
]
}
pub fn hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
vec![
Span::raw(" "),
Span::styled(key, red_text()),
Span::raw(" "),
Span::raw(action),
Span::raw(" ⋮"),
]
}
pub fn last_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
vec![
Span::raw(" "),
Span::styled(key, red_text()),
Span::raw(" "),
Span::raw(action),
]
}
fn common_detail_hotkeys() -> Vec<Span<'static>> {
let mut spans = vec![];
spans.extend(first_hint("↑↓", "scroll"));
spans.extend(hint("⎋", "back"));
spans.extend(hint("[]", "switch"));
spans.extend(hint("⇤⇥", "switch"));
spans.extend(hint("^u", "page up"));
spans.extend(hint("^d", "page down"));
spans.extend(hint("y", "yank"));
spans.extend(hint("^o", "console"));
spans.extend(hint("p", "preferences"));
spans.extend(hint("^p", "print"));
spans.extend(hint("^r", "refresh"));
spans.extend(hint("^w", "close"));
spans.extend(last_hint("q", "quit"));
spans
}
fn common_list_hotkeys() -> Vec<Span<'static>> {
let mut spans = vec![];
spans.extend(first_hint("↑↓", "scroll"));
spans.extend(hint("←→", "toggle"));
spans.extend(hint("⏎", "open"));
spans.extend(hint("[]", "switch"));
spans.extend(hint("^u", "page up"));
spans.extend(hint("^d", "page down"));
spans.extend(hint("y", "yank"));
spans.extend(hint("^o", "console"));
spans.extend(hint("p", "preferences"));
spans.extend(hint("^p", "print"));
spans.extend(hint("^r", "refresh"));
spans.extend(hint("^w", "close"));
spans.extend(last_hint("q", "quit"));
spans
}
fn dynamodb_items_hotkeys(app: &App) -> Vec<Span<'static>> {
let mut hints = vec![];
if can_load_more(app) {
let action = if app.dynamodb_state.items_failed {
"retry load more"
} else {
"load more"
};
hints.extend(first_hint("m", action));
hints.push(Span::raw(" "));
}
hints.extend(common_detail_hotkeys());
hints
}
pub fn render_bottom_bar(frame: &mut Frame, app: &App, area: Rect) {
let is_insert = match app.mode {
Mode::FilterInput | Mode::EventFilterInput | Mode::InsightsInput => true,
Mode::ResourceFinder => app.resource_finder.focus == FinderFocus::Filter,
Mode::ServicePicker | Mode::SpaceMenu => app.service_picker.filter_active,
Mode::RegionPicker => app.region_filter_active,
Mode::SessionPicker => app.session_filter_active,
Mode::ProfilePicker => app.profile_filter_active,
_ => false,
};
let mode_indicator = if is_insert { " INSERT " } else { " NORMAL " };
let mode_style = if is_insert {
Style::default().bg(Color::Yellow).fg(Color::Black)
} else {
Style::default().bg(Color::Blue).fg(Color::White)
};
let help = if app.mode == Mode::ResourceFinder {
let mut hints = if app.resource_finder.focus == FinderFocus::Filter {
first_hint("⏎", "results")
} else {
let mut hints = first_hint("↑↓", "select");
hints.extend(hint("⏎", "open"));
hints.extend(hint("i", "filter"));
hints
};
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("^r", "refresh"));
hints.extend(last_hint("⎋", "cancel"));
hints
} else if app.mode == Mode::QuitConfirm {
let mut hints = vec![];
hints.extend(first_hint("y", "confirm quit"));
hints.extend(last_hint("n / Esc", "cancel"));
hints
} else if app.mode == Mode::ColumnSelector {
let mut hints = vec![];
hints.extend(hint("↑↓", "scroll"));
hints.extend(hint("␣", "toggle"));
if app.current_service == Service::CloudWatchAlarms {
hints.extend(hint("⇤⇥", "switch"));
}
hints.extend(last_hint("⎋", "close"));
hints
} else if app.mode == Mode::InsightsInput {
let mut hints = vec![];
hints.extend(hint(" tab", "switch"));
hints.extend(hint("↑↓", "scroll"));
hints.extend(hint("␣", "toggle"));
hints.extend(hint("⏎", "execute"));
hints.extend(hint("⎋", "cancel"));
hints.extend(hint("^r", "refresh"));
hints.extend(last_hint("^w", "close"));
hints
} else if app.current_service == Service::CloudWatchInsights {
let mut hints = vec![];
hints.extend(hint(" i", "insert"));
hints.extend(hint("⏎", "execute"));
hints.extend(hint("?", "help"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^o", "console"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.mode == Mode::EventFilterInput {
let mut hints = vec![];
hints.extend(first_hint("⇤⇥", "switch"));
hints.extend(hint("␣", "change unit"));
hints.extend(hint("⏎", "apply"));
hints.extend(hint("⎋", "cancel"));
hints.extend(last_hint("^w", "close"));
hints
} else if app.mode == Mode::RegionPicker {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("i", "filter"));
hints.extend(hint("⏎", "select"));
hints.extend(hint("^l", "measure latency"));
hints.extend(hint("⎋", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.mode == Mode::FilterInput {
let mut hints = vec![];
hints.extend(first_hint("⏎", "apply"));
hints.extend(hint("⎋", "cancel"));
hints.extend(hint("␣", "toggle"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(last_hint("^w", "close"));
hints
} else if app.mode == Mode::Normal
&& app.service_selected
&& app.current_service == Service::DynamoDbTables
&& is_items_tab(app)
{
dynamodb_items_hotkeys(app)
} else if app.mode == Mode::Normal
&& app.service_selected
&& app.current_service == Service::RdsInstances
{
let mut hints = first_hint("↑↓", "scroll");
hints.extend(hint("⏎ / ←→", "toggle"));
hints.extend(hint("i", "filter"));
hints.extend(hint("p", "preferences"));
hints.extend(hint("^p", "print"));
hints.extend(hint("y", "copy ARN"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.view_mode == ViewMode::Events {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("p", "preferences"));
hints.extend(hint("^p", "print"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.current_service == Service::CloudWatchAlarms
&& app.alarms_state.current_alarm.is_some()
{
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("⇤⇥", "switch tab"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("y", "copy JSON"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.view_mode == ViewMode::Detail {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("i", "insert"));
hints.extend(hint("s", "sort"));
hints.extend(hint("o", "order"));
hints.extend(hint("<num>p", "page"));
hints.extend(hint("^o", "console"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("p", "preferences"));
hints.extend(hint("^p", "print"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.current_service == Service::EcrRepositories {
if app.ecr_state.current_repository.is_some() {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
}
} else if app.current_service == Service::S3Buckets {
if app.s3_state.current_bucket.is_some() {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
}
} else if app.current_service == Service::CloudFormationStacks {
if app.cfn_state.current_stack.is_some() {
if app.cfn_state.detail_tab == DetailTab::Template
|| app.cfn_state.detail_tab == DetailTab::GitSync
{
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("[]", "switch"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("^u", "page up"));
hints.extend(hint("^d", "page down"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else {
common_detail_hotkeys()
}
} else {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("[]", "switch"));
hints.extend(hint("^u", "page up"));
hints.extend(hint("^d", "page down"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^p", "snapshot"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
}
} else if app.current_service == Service::IamUsers {
if app.iam_state.current_user.is_some() {
common_detail_hotkeys()
} else {
common_list_hotkeys()
}
} else if app.current_service == Service::IamRoles {
if app.iam_state.current_role.is_some() {
common_detail_hotkeys()
} else {
common_list_hotkeys()
}
} else if app.current_service == Service::LambdaFunctions {
if app.lambda_state.current_function.is_some() {
common_detail_hotkeys()
} else {
common_list_hotkeys()
}
} else if app.view_mode == ViewMode::List {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("^o", "console"));
hints.extend(hint("p", "preferences"));
hints.extend(hint("^p", "print"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("<num>p", "page"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("p", "preferences"));
hints.extend(hint("^p", "print"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
};
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let is_loading = app.log_groups_state.loading
|| app.rds_state.instances.loading
|| app.kms_state.keys.loading
|| app.ecr_state.repositories.loading
|| app.ec2_state.table.loading
|| app.cfn_state.table.loading
|| app.lambda_state.table.loading
|| app.lambda_application_state.table.loading
|| app.alarms_state.table.loading
|| app.cloudtrail_state.table.loading
|| app.apig_state.apis.loading
|| app.efs_state.file_systems.loading
|| app.sqs_state.queues.loading;
let spinner_frame = if is_loading {
SPINNER_FRAMES[(millis / 100 % SPINNER_FRAMES.len() as u128) as usize]
} else {
" "
};
let connection_message: Option<String> =
if app.log_groups_state.loading && !app.log_groups_state.loading_message.is_empty() {
let msg = &app.log_groups_state.loading_message;
if msg.starts_with("Connecting") || msg.starts_with("Loading log") {
let max_width = area.width.saturating_sub(10) as usize;
Some(if msg.len() > max_width {
format!("{}...", &msg[..max_width.saturating_sub(3)])
} else {
msg.clone()
})
} else {
None
}
} else {
None
};
let status_line_temp = if let Some(msg) = connection_message {
Some(Line::from(vec![Span::raw(msg)]))
} else if !app.page_input.is_empty() {
Some(Line::from(vec![Span::raw(format!(
"Go to page {} (press p to confirm)",
app.page_input
))]))
} else {
None
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(8),
Constraint::Length(2),
Constraint::Min(0),
])
.split(area);
let mode_widget = Paragraph::new(mode_indicator).style(mode_style);
let spinner_widget = Paragraph::new(spinner_frame)
.block(Block::default())
.style(Style::default().bg(Color::DarkGray).fg(Color::Yellow));
let version = env!("CARGO_PKG_VERSION");
let commit = option_env!("GIT_HASH").unwrap_or("unknown");
let version_text = format!("⋮ RUSTICITY v{} (#{})", version, commit);
let colors = [
Color::Red,
Color::Green,
Color::Yellow,
Color::Blue,
Color::Magenta,
Color::Cyan,
];
let seed = millis as usize;
let version_spans: Vec<Span> = version_text
.chars()
.enumerate()
.map(|(i, c)| {
let color = colors[(seed + i) % colors.len()];
Span::styled(c.to_string(), Style::default().fg(color))
})
.collect();
let right_line = if is_loading {
let spin = SPINNER_FRAMES[(millis / 100 % SPINNER_FRAMES.len() as u128) as usize];
let mut spans = vec![Span::styled(
format!("Loading… {} ", spin),
Style::default().fg(Color::Yellow),
)];
spans.extend(version_spans);
Line::from(spans)
} else {
Line::from(version_spans)
};
let loading_prefix_len: u16 = if is_loading { 11 } else { 0 };
let right_width = version_text.len() as u16 + loading_prefix_len;
if let Some(line) = status_line_temp {
let status_widget = Paragraph::new(line)
.alignment(Alignment::Left)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
frame.render_widget(mode_widget, chunks[0]);
frame.render_widget(spinner_widget, chunks[1]);
frame.render_widget(status_widget, chunks[2]);
} else {
let status_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(right_width)])
.split(chunks[2]);
let help_widget = Paragraph::new(Line::from(help))
.block(Block::default())
.alignment(Alignment::Left)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
let version_widget = Paragraph::new(right_line)
.alignment(Alignment::Right)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
frame.render_widget(mode_widget, chunks[0]);
frame.render_widget(spinner_widget, chunks[1]);
frame.render_widget(help_widget, status_chunks[0]);
frame.render_widget(version_widget, status_chunks[1]);
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_version_string_format() {
let version = env!("CARGO_PKG_VERSION");
let commit = option_env!("GIT_HASH").unwrap_or("unknown");
let version_text = format!("RUSTICITY v{} (#{})", version, commit);
assert!(version_text.starts_with("RUSTICITY v"));
assert!(version_text.contains("#"));
}
#[test]
fn test_version_padding_calculation() {
let help_text = " ↑↓ scroll ⋮ ←→ toggle ⋮ ⏎ open ⋮ ^o console ⋮ p preferences ⋮ ^p print ⋮ ^r refresh ⋮ ^w close ⋮ q quit ";
let help_width: usize = help_text.len();
let version = env!("CARGO_PKG_VERSION");
let commit = option_env!("GIT_HASH").unwrap_or("unknown");
let version_text = format!("RUSTICITY v{} (#{})", version, commit);
let version_width: usize = version_text.len();
let status_width: usize = 200;
let padding: usize = status_width
.saturating_sub(help_width)
.saturating_sub(version_width);
assert_eq!(help_width + padding + version_width, status_width);
}
#[test]
fn test_preferences_hint_uses_p_not_ctrl_p() {
use super::common_list_hotkeys;
let spans = common_list_hotkeys();
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("p preferences"),
"Should show 'p preferences'"
);
assert!(
!text.contains("^p preferences"),
"Should not show '^p preferences'"
);
}
#[test]
fn test_print_hint_uses_ctrl_p() {
use super::common_list_hotkeys;
let spans = common_list_hotkeys();
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("^p print"),
"Should show '^p print' for copy to clipboard"
);
}
#[test]
fn test_yank_hint_uses_y() {
use super::common_list_hotkeys;
let spans = common_list_hotkeys();
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("y yank"),
"Should show 'y yank' for copying selected item"
);
}
#[test]
fn test_common_detail_hotkeys_has_correct_hints() {
use super::common_detail_hotkeys;
let spans = common_detail_hotkeys();
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("p preferences"),
"Detail view should show 'p preferences'"
);
assert!(
text.contains("^p print"),
"Detail view should show '^p print'"
);
}
#[test]
fn test_no_columns_hint_anywhere() {
let list_spans = super::common_list_hotkeys();
let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!list_text.contains("p columns"),
"Should not show 'p columns', use 'p preferences' instead"
);
let detail_spans = super::common_detail_hotkeys();
let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!detail_text.contains("p columns"),
"Should not show 'p columns', use 'p preferences' instead"
);
}
#[test]
fn test_all_views_have_print_hotkey() {
let list_spans = super::common_list_hotkeys();
let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
list_text.contains("^p print"),
"List view must have '^p print' hotkey"
);
let detail_spans = super::common_detail_hotkeys();
let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
detail_text.contains("^p print"),
"Detail view must have '^p print' hotkey"
);
}
#[test]
fn test_preferences_always_uses_p_not_ctrl_p() {
let list_spans = super::common_list_hotkeys();
let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
list_text.contains("p preferences"),
"Should use 'p preferences'"
);
assert!(
!list_text.contains("^p preferences"),
"Should not use '^p preferences'"
);
let detail_spans = super::common_detail_hotkeys();
let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
detail_text.contains("p preferences"),
"Should use 'p preferences'"
);
assert!(
!detail_text.contains("^p preferences"),
"Should not use '^p preferences'"
);
}
#[test]
fn test_is_loading_true_when_kms_loading() {
use crate::app::App;
let mut app = App::new_without_client("default".to_string(), None);
app.kms_state.keys.loading = true;
let is_loading = app.log_groups_state.loading
|| app.kms_state.keys.loading
|| app.ecr_state.repositories.loading;
assert!(is_loading, "is_loading must be true when kms keys loading");
}
#[test]
fn test_is_loading_false_when_nothing_loading() {
use crate::app::App;
let app = App::new_without_client("default".to_string(), None);
let is_loading = app.log_groups_state.loading
|| app.kms_state.keys.loading
|| app.ecr_state.repositories.loading
|| app.ec2_state.table.loading;
assert!(
!is_loading,
"is_loading must be false when nothing is loading"
);
}
#[test]
fn test_refreshing_message_not_set_on_refresh() {
use crate::app::App;
use crate::keymap::{Action, Mode};
let mut app = App::new_without_client("default".to_string(), None);
app.mode = Mode::ProfilePicker;
app.handle_action(Action::Refresh);
assert!(
app.log_groups_state.loading_message.is_empty()
|| !app.log_groups_state.loading_message.contains("Refreshing"),
"Refresh must not set 'Refreshing...' loading_message: got {:?}",
app.log_groups_state.loading_message
);
}
#[test]
fn test_spinner_off_when_all_loading_false() {
use crate::app::App;
let app = App::new_without_client("default".to_string(), None);
let is_loading = app.log_groups_state.loading
|| app.kms_state.keys.loading
|| app.ecr_state.repositories.loading
|| app.ec2_state.table.loading
|| app.cfn_state.table.loading
|| app.lambda_state.table.loading
|| app.lambda_application_state.table.loading
|| app.alarms_state.table.loading
|| app.cloudtrail_state.table.loading
|| app.apig_state.apis.loading
|| app.sqs_state.queues.loading;
assert!(!is_loading, "spinner must be OFF when nothing is loading");
}
#[test]
fn test_spinner_on_then_off_lifecycle() {
use crate::app::App;
let mut app = App::new_without_client("default".to_string(), None);
app.kms_state.keys.loading = true;
let spinning = app.kms_state.keys.loading;
assert!(spinning, "spinner must be ON during load");
app.kms_state.keys.loading = false;
let spinning_after = app.kms_state.keys.loading;
assert!(!spinning_after, "spinner must be OFF after load completes");
}
#[test]
fn test_spinner_on_for_each_service() {
use crate::app::App;
let check = |loading: bool| loading;
assert!(check(true), "loading=true must trigger spinner");
assert!(!check(false), "loading=false must not trigger spinner");
let mut app = App::new_without_client("default".to_string(), None);
type LoadSetter = Box<dyn Fn(&mut App, bool)>;
let cases: Vec<(&str, LoadSetter)> = vec![
(
"kms",
Box::new(|a: &mut App, v| a.kms_state.keys.loading = v),
),
(
"ecr",
Box::new(|a: &mut App, v| a.ecr_state.repositories.loading = v),
),
(
"ec2",
Box::new(|a: &mut App, v| a.ec2_state.table.loading = v),
),
(
"cfn",
Box::new(|a: &mut App, v| a.cfn_state.table.loading = v),
),
(
"lambda_fn",
Box::new(|a: &mut App, v| a.lambda_state.table.loading = v),
),
(
"alarms",
Box::new(|a: &mut App, v| a.alarms_state.table.loading = v),
),
];
for (name, setter) in cases {
setter(&mut app, true);
let is_loading = app.kms_state.keys.loading
|| app.ecr_state.repositories.loading
|| app.ec2_state.table.loading
|| app.cfn_state.table.loading
|| app.lambda_state.table.loading
|| app.alarms_state.table.loading;
assert!(is_loading, "spinner must be ON when {name} is loading");
setter(&mut app, false);
}
}
#[test]
fn test_connection_message_filter_allows_connecting() {
let allowed = ["Connecting with new region...", "Loading log groups..."];
for msg in &allowed {
assert!(
msg.starts_with("Connecting") || msg.starts_with("Loading log"),
"Message '{msg}' should pass connection filter"
);
}
let blocked = ["Refreshing...", "Loading KMS keys...", ""];
for msg in &blocked {
assert!(
!(msg.starts_with("Connecting") || msg.starts_with("Loading log")),
"Message '{msg}' should be blocked by connection filter"
);
}
}
}
#[cfg(test)]
mod dynamodb_hint_tests {
use super::*;
use crate::dynamodb::items::context;
use crate::ui::dynamodb::DetailTab as DdbDetailTab;
use aws_sdk_dynamodb::types::AttributeValue;
use ratatui::{backend::TestBackend, Terminal};
use rusticity_core::dynamodb::DdbTable;
fn items_app() -> App {
let mut app = App::new_without_client("test".into(), Some("us-east-2".into()));
app.current_service = Service::DynamoDbTables;
app.service_selected = true;
app.mode = Mode::Normal;
app.dynamodb_state.current_table = Some("orders".into());
app.dynamodb_state.detail_tab = DdbDetailTab::Items;
app.dynamodb_state.tables.items = vec![DdbTable {
name: "orders".into(),
partition_key: "pk (S)".into(),
..Default::default()
}];
app.dynamodb_state.items_context = context(&app);
app.dynamodb_state.items_cursor = Some(
[("pk".into(), AttributeValue::S("next".into()))]
.into_iter()
.collect(),
);
app
}
fn status_text(app: &App, width: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap();
terminal
.draw(|f| render_bottom_bar(f, app, f.area()))
.unwrap();
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
#[test]
fn items_load_more_hint_precedes_generic_view_hints() {
let mut app = items_app();
for mode in [ViewMode::List, ViewMode::Detail, ViewMode::Events] {
app.view_mode = mode;
let text = status_text(&app, 300);
assert!(
text.contains("m load more"),
"Missing hint with {mode:?}: {text}"
);
assert!(text.contains("p preferences"));
assert!(text.contains("^r refresh"));
}
}
#[test]
fn items_load_more_hint_is_visible_in_an_eighty_column_terminal() {
assert!(status_text(&items_app(), 80).contains("m load more"));
}
#[test]
fn items_load_more_hint_only_advertises_an_available_action() {
let cases: &[fn(&mut App)] = &[
|a| a.current_service = Service::EcrRepositories,
|a| a.service_selected = false,
|a| a.dynamodb_state.current_table = None,
|a| a.dynamodb_state.detail_tab = DdbDetailTab::Settings,
|a| a.dynamodb_state.items_cursor = None,
|a| a.dynamodb_state.items_context = None,
|a| a.dynamodb_state.items_loading = true,
|a| a.profile = "another-profile".into(),
];
for configure in cases {
let mut app = items_app();
configure(&mut app);
assert!(!status_text(&app, 300).contains("load more"));
}
for mode in [
Mode::FilterInput,
Mode::ColumnSelector,
Mode::ErrorModal,
Mode::HelpModal,
Mode::QuitConfirm,
Mode::ProfilePicker,
Mode::RegionPicker,
Mode::CalendarPicker,
Mode::TabPicker,
Mode::SessionPicker,
Mode::SpaceMenu,
Mode::EventFilterInput,
Mode::InsightsInput,
] {
let mut app = items_app();
app.mode = mode;
assert!(
!status_text(&app, 300).contains("load more"),
"Unexpected hint in {mode:?}"
);
}
}
#[test]
fn items_failed_continuation_shows_retry_hint_only_with_a_valid_cursor() {
let mut app = items_app();
app.dynamodb_state.items_failed = true;
assert!(status_text(&app, 80).contains("m retry load more"));
app.dynamodb_state.items_cursor = None;
assert!(!status_text(&app, 300).contains("load more"));
}
}