pub mod apig;
pub mod cfn;
pub mod cloudtrail;
pub mod cw;
pub mod ec2;
pub mod ecr;
mod expanded_view;
pub mod filter;
pub mod iam;
pub mod lambda;
pub mod monitoring;
mod pagination;
pub mod prefs;
mod query_editor;
pub mod s3;
pub mod sqs;
mod status;
pub mod styles;
pub mod table;
pub mod tree;
pub use cw::insights::{DateRangeType, TimeUnit};
pub use cw::{
CloudWatchLogGroupsState, DetailTab, EventColumn, EventFilterFocus, LogGroupColumn,
StreamColumn, StreamSort,
};
pub use expanded_view::{format_expansion_text, format_fields};
pub use pagination::{render_paginated_filter, PaginatedFilterConfig};
pub use prefs::Preferences;
pub use query_editor::{render_query_editor, QueryEditorConfig};
pub use status::{first_hint, hint, last_hint, SPINNER_FRAMES};
pub use table::{format_expandable, CURSOR_COLLAPSED, CURSOR_EXPANDED};
pub const PAGE_SIZE_OPTIONS: &[(PageSize, &str)] = &[
(PageSize::Ten, "10"),
(PageSize::TwentyFive, "25"),
(PageSize::Fifty, "50"),
(PageSize::OneHundred, "100"),
];
pub const PAGE_SIZE_OPTIONS_SMALL: &[(PageSize, &str)] = &[
(PageSize::Ten, "10"),
(PageSize::TwentyFive, "25"),
(PageSize::Fifty, "50"),
];
pub const MAX_DETAIL_COLUMNS: usize = 3;
use self::styles::highlight;
use crate::app::{
AlarmViewMode, App, CalendarField, CloudTrailDetailFocus, LambdaDetailTab, Service, ViewMode,
};
use crate::cfn::Column as CfnColumn;
use crate::cloudtrail::{CloudTrailEventColumn, EventResourceColumn};
use crate::common::{render_pagination_text, render_scrollbar, translate_column, PageSize};
use crate::cw::alarms::AlarmColumn;
use crate::ec2::Column as Ec2Column;
use crate::ecr::{image, repo};
use crate::iam::{RoleColumn, UserColumn};
use crate::keymap::Mode;
use crate::lambda::{ApplicationColumn, DeploymentColumn, FunctionColumn, ResourceColumn};
use crate::s3::BucketColumn;
use crate::sqs::pipe::Column as SqsPipeColumn;
use crate::sqs::queue::Column as SqsColumn;
use crate::sqs::sub::Column as SqsSubscriptionColumn;
use crate::sqs::tag::Column as SqsTagColumn;
use crate::sqs::trigger::Column as SqsTriggerColumn;
use crate::ui::cfn::{
ChangeSetColumn as CfnChangeSetColumn, DetailTab as CfnDetailTab,
EventColumn as CfnEventColumn, OutputColumn, ParameterColumn,
ResourceColumn as CfnResourceColumn,
};
use crate::ui::iam::{RoleTab, UserTab};
use crate::ui::lambda::ApplicationDetailTab;
use crate::ui::sqs::QueueDetailTab as SqsQueueDetailTab;
use crate::ui::table::Column as TableColumn;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
pub fn labeled_field(label: &str, value: impl Into<String>) -> Line<'static> {
let val = value.into();
let display = if val.is_empty() { "-".to_string() } else { val };
Line::from(vec![
Span::styled(
format!("{}: ", label),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(display),
])
}
pub fn block_height(lines: &[Line]) -> u16 {
lines.len() as u16 + 2
}
pub fn block_height_for(line_count: usize) -> u16 {
line_count as u16 + 2
}
pub fn section_header(text: &str, width: u16) -> Line<'static> {
let text_len = text.len() as u16;
let remaining = width.saturating_sub(text_len + 3);
let dashes = "─".repeat(remaining as usize);
Line::from(vec![
Span::raw("─ "),
Span::raw(text.to_string()),
Span::raw(format!(" {}", dashes)),
])
}
pub fn tab_style(selected: bool) -> Style {
if selected {
highlight()
} else {
Style::default()
}
}
pub fn service_tab_style(selected: bool) -> Style {
if selected {
Style::default().bg(Color::Green).fg(Color::Black)
} else {
Style::default()
}
}
pub fn render_tab_spans<'a>(tabs: &[(&'a str, bool)]) -> Vec<Span<'a>> {
let mut spans = Vec::new();
for (i, (name, selected)) in tabs.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ⋮ "));
}
spans.push(Span::styled(*name, service_tab_style(*selected)));
}
spans
}
use ratatui::{prelude::*, widgets::*};
pub const SEARCH_ICON: &str = "─ 🔍 ─";
pub const PREFERENCES_TITLE: &str = "Preferences";
pub fn filter_area(filter_text: Vec<Span<'_>>, is_active: bool) -> Paragraph<'_> {
Paragraph::new(Line::from(filter_text))
.block(
Block::default()
.title(SEARCH_ICON)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(if is_active {
active_border()
} else {
Style::default()
}),
)
.style(Style::default())
}
pub fn active_border() -> Style {
Style::default().fg(Color::Green)
}
pub fn rounded_block() -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
}
pub fn format_title(title: &str) -> String {
format!("─ {} ─", title.trim())
}
pub fn titled_block(title: impl Into<String>) -> Block<'static> {
rounded_block().title(format_title(&title.into()))
}
pub fn titled_rounded_block(title: &'static str) -> Block<'static> {
titled_block(title)
}
pub fn bold_style() -> Style {
Style::default().add_modifier(Modifier::BOLD)
}
pub fn cyan_bold() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
}
pub fn red_text() -> Style {
Style::default().fg(Color::Rgb(255, 165, 0))
}
pub fn yellow_text() -> Style {
Style::default().fg(Color::Yellow)
}
pub fn get_cursor(active: bool) -> &'static str {
if active {
"█"
} else {
""
}
}
pub fn render_search_filter(
frame: &mut Frame,
area: Rect,
filter_text: &str,
is_active: bool,
selected: usize,
total_items: usize,
page_size: usize,
) {
let cursor = get_cursor(is_active);
let total_pages = total_items.div_ceil(page_size);
let current_page = selected / page_size;
let pagination = render_pagination_text(current_page, total_pages);
let controls_text = format!(" {}", pagination);
let filter_width = (area.width as usize).saturating_sub(4);
let content_len = filter_text.len() + if is_active { cursor.len() } else { 0 };
let available_space = filter_width.saturating_sub(controls_text.len() + 1);
let mut spans = vec![];
if filter_text.is_empty() && !is_active {
spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
} else {
spans.push(Span::raw(filter_text));
}
if is_active {
spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
}
if content_len < available_space {
spans.push(Span::raw(
" ".repeat(available_space.saturating_sub(content_len)),
));
}
spans.push(Span::styled(
controls_text,
if is_active {
Style::default()
} else {
Style::default().fg(Color::Green)
},
));
let filter = filter_area(spans, is_active);
frame.render_widget(filter, area);
}
fn render_toggle(is_on: bool) -> Vec<Span<'static>> {
if is_on {
vec![
Span::styled("◼", Style::default().fg(Color::Blue)),
Span::raw("⬜"),
]
} else {
vec![
Span::raw("⬜"),
Span::styled("◼", Style::default().fg(Color::Black)),
]
}
}
fn render_radio(is_selected: bool) -> (String, Style) {
if is_selected {
("●".to_string(), Style::default().fg(Color::Blue))
} else {
("○".to_string(), Style::default())
}
}
pub fn vertical(
constraints: impl IntoIterator<Item = Constraint>,
area: Rect,
) -> std::rc::Rc<[Rect]> {
Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area)
}
pub fn horizontal(
constraints: impl IntoIterator<Item = Constraint>,
area: Rect,
) -> std::rc::Rc<[Rect]> {
Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(area)
}
pub fn block(title: &str) -> Block<'_> {
rounded_block().title(title)
}
pub fn block_with_style(title: &str, style: Style) -> Block<'_> {
titled_block(title).border_style(style)
}
pub fn render_fields_with_dynamic_columns(frame: &mut Frame, area: Rect, fields: Vec<Line>) -> u16 {
use ratatui::widgets::Paragraph;
if fields.is_empty() {
return 0;
}
let field_widths: Vec<u16> = fields
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.len() as u16)
.sum::<u16>()
+ 2
})
.collect();
let max_field_width = *field_widths.iter().max().unwrap_or(&20);
let available_width = area.width;
let num_columns = (available_width / max_field_width)
.max(1)
.min(MAX_DETAIL_COLUMNS as u16)
.min(fields.len() as u16) as usize;
let total_fields = fields.len();
let base_per_column = total_fields / num_columns;
let extra = total_fields % num_columns;
let mut columns: Vec<Vec<Line>> = Vec::new();
let mut field_idx = 0;
for col in 0..num_columns {
let fields_in_this_col = if col < extra {
base_per_column + 1
} else {
base_per_column
};
let mut column_fields = Vec::new();
for _ in 0..fields_in_this_col {
if field_idx < fields.len() {
column_fields.push(fields[field_idx].clone());
field_idx += 1;
}
}
columns.push(column_fields);
}
let max_rows = columns.iter().map(|c| c.len()).max().unwrap_or(1) as u16;
let constraints: Vec<Constraint> = (0..num_columns)
.map(|_| Constraint::Percentage(100 / num_columns as u16))
.collect();
let column_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(area);
for (i, column_fields) in columns.iter().enumerate() {
if i < column_layout.len() {
frame.render_widget(Paragraph::new(column_fields.clone()), column_layout[i]);
}
}
max_rows
}
pub fn calculate_dynamic_height(fields: &[Line], width: u16) -> u16 {
if fields.is_empty() {
return 0;
}
let field_widths: Vec<u16> = fields
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.len() as u16)
.sum::<u16>()
+ 2
})
.collect();
let max_field_width = *field_widths.iter().max().unwrap_or(&20);
let num_columns = (width / max_field_width)
.max(1)
.min(MAX_DETAIL_COLUMNS as u16)
.min(fields.len() as u16) as usize;
let base = fields.len() / num_columns;
let extra = fields.len() % num_columns;
let max_rows = if extra > 0 { base + 1 } else { base };
max_rows as u16
}
pub fn render_summary(frame: &mut Frame, area: Rect, title: &str, fields: &[(&str, String)]) {
let summary_block = titled_block(title);
let inner = summary_block.inner(area);
frame.render_widget(summary_block, area);
let lines: Vec<Line> = fields
.iter()
.map(|(label, value)| labeled_field(label, value.clone()))
.collect();
render_fields_with_dynamic_columns(frame, inner, lines);
}
pub fn render_tabs<T: PartialEq>(frame: &mut Frame, area: Rect, tabs: &[(&str, T)], selected: &T) {
let spans: Vec<Span> = tabs
.iter()
.enumerate()
.flat_map(|(i, (name, tab))| {
let mut result = Vec::new();
if i > 0 {
result.push(Span::raw(" ⋮ "));
}
if tab == selected {
result.push(Span::styled(*name, tab_style(true)));
} else {
result.push(Span::raw(*name));
}
result
})
.collect();
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
pub fn format_duration(seconds: u64) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
const WEEK: u64 = 7 * DAY;
const YEAR: u64 = 365 * DAY;
if seconds >= YEAR {
let years = seconds / YEAR;
let remainder = seconds % YEAR;
if remainder == 0 {
format!("{} year{}", years, if years == 1 { "" } else { "s" })
} else {
let weeks = remainder / WEEK;
format!(
"{} year{} {} week{}",
years,
if years == 1 { "" } else { "s" },
weeks,
if weeks == 1 { "" } else { "s" }
)
}
} else if seconds >= WEEK {
let weeks = seconds / WEEK;
let remainder = seconds % WEEK;
if remainder == 0 {
format!("{} week{}", weeks, if weeks == 1 { "" } else { "s" })
} else {
let days = remainder / DAY;
format!(
"{} week{} {} day{}",
weeks,
if weeks == 1 { "" } else { "s" },
days,
if days == 1 { "" } else { "s" }
)
}
} else if seconds >= DAY {
let days = seconds / DAY;
let remainder = seconds % DAY;
if remainder == 0 {
format!("{} day{}", days, if days == 1 { "" } else { "s" })
} else {
let hours = remainder / HOUR;
format!(
"{} day{} {} hour{}",
days,
if days == 1 { "" } else { "s" },
hours,
if hours == 1 { "" } else { "s" }
)
}
} else if seconds >= HOUR {
let hours = seconds / HOUR;
let remainder = seconds % HOUR;
if remainder == 0 {
format!("{} hour{}", hours, if hours == 1 { "" } else { "s" })
} else {
let minutes = remainder / MINUTE;
format!(
"{} hour{} {} minute{}",
hours,
if hours == 1 { "" } else { "s" },
minutes,
if minutes == 1 { "" } else { "s" }
)
}
} else if seconds >= MINUTE {
let minutes = seconds / MINUTE;
format!("{} minute{}", minutes, if minutes == 1 { "" } else { "s" })
} else {
format!("{} second{}", seconds, if seconds == 1 { "" } else { "s" })
}
}
fn render_column_toggle_string(col_name: &str, is_visible: bool) -> (ListItem<'static>, usize) {
let mut spans = vec![];
spans.extend(render_toggle(is_visible));
spans.push(Span::raw(" "));
spans.push(Span::raw(col_name.to_string()));
let text_len = 4 + col_name.len();
(ListItem::new(Line::from(spans)), text_len)
}
fn render_section_header(title: &str) -> (ListItem<'static>, usize) {
let len = title.len();
(
ListItem::new(Line::from(Span::styled(
title.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))),
len,
)
}
fn render_radio_item(label: &str, is_selected: bool, indent: bool) -> (ListItem<'static>, usize) {
let (radio, style) = render_radio(is_selected);
let text_len = (if indent { 2 } else { 0 }) + radio.chars().count() + 1 + label.len();
let mut spans = if indent {
vec![Span::raw(" ")]
} else {
vec![]
};
spans.push(Span::styled(radio, style));
spans.push(Span::raw(format!(" {}", label)));
(ListItem::new(Line::from(spans)), text_len)
}
fn render_page_size_section(
current_size: PageSize,
sizes: &[(PageSize, &str)],
) -> (Vec<ListItem<'static>>, usize) {
let mut items = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Page size");
items.push(header);
max_len = max_len.max(header_len);
for (size, label) in sizes {
let is_selected = current_size == *size;
let (item, len) = render_radio_item(label, is_selected, false);
items.push(item);
max_len = max_len.max(len);
}
(items, max_len)
}
pub fn render(frame: &mut Frame, app: &App) {
let area = frame.area();
let has_tabs = !app.tabs.is_empty();
let show_breadcrumbs = has_tabs && app.service_selected && {
match app.current_service {
Service::S3Buckets => app.s3_state.current_bucket.is_some(),
_ => false,
}
};
let chunks = if show_breadcrumbs {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), ])
.split(area)
};
render_tabs_row(frame, app, chunks[0]);
if show_breadcrumbs {
render_top_bar(frame, app, chunks[1]);
}
let content_idx = if show_breadcrumbs { 2 } else { 1 };
let bottom_idx = if show_breadcrumbs { 3 } else { 2 };
if !app.service_selected && app.tabs.is_empty() && app.mode == Mode::Normal {
let message = vec![
Line::from(""),
Line::from(""),
Line::from(vec![
Span::raw("Press "),
Span::styled("␣", Style::default().fg(Color::Red)),
Span::raw(" to open Menu"),
]),
];
let paragraph = Paragraph::new(message).alignment(Alignment::Center);
frame.render_widget(paragraph, chunks[content_idx]);
render_bottom_bar(frame, app, chunks[bottom_idx]);
} else if !app.service_selected && app.mode == Mode::Normal {
render_service_picker(frame, app, chunks[content_idx]);
render_bottom_bar(frame, app, chunks[bottom_idx]);
} else if app.service_selected {
render_service(frame, app, chunks[content_idx]);
render_bottom_bar(frame, app, chunks[bottom_idx]);
} else {
render_bottom_bar(frame, app, chunks[bottom_idx]);
}
match app.mode {
Mode::SpaceMenu => render_space_menu(frame, area),
Mode::ServicePicker => render_service_picker(frame, app, area),
Mode::ColumnSelector => render_column_selector(frame, app, area),
Mode::ErrorModal => render_error_modal(frame, app, area),
Mode::HelpModal => render_help_modal(frame, area),
Mode::RegionPicker => render_region_selector(frame, app, area),
Mode::ProfilePicker => render_profile_picker(frame, app, area),
Mode::CalendarPicker => render_calendar_picker(frame, app, area),
Mode::TabPicker => render_tab_picker(frame, app, area),
Mode::SessionPicker => render_session_picker(frame, app, area),
_ => {}
}
}
fn render_tabs_row(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
let now = chrono::Utc::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
let (identity_label, identity_value) = if app.config.role_arn.is_empty() {
("Identity:", "N/A".to_string())
} else if let Some(role_part) = app.config.role_arn.split("assumed-role/").nth(1) {
(
"Role:",
role_part.split('/').next().unwrap_or("N/A").to_string(),
)
} else if let Some(user_part) = app.config.role_arn.split(":user/").nth(1) {
("User:", user_part.to_string())
} else {
("Identity:", "N/A".to_string())
};
let region_display = if app.config.region_auto_detected {
format!(" {} ⚡ ⋮ ", app.config.region)
} else {
format!(" {} ⋮ ", app.config.region)
};
let info_spans = vec![
Span::styled(
"Profile:",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} ⋮ ", app.profile),
Style::default().fg(Color::White),
),
Span::styled(
"Account:",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} ⋮ ", app.config.account_id),
Style::default().fg(Color::White),
),
Span::styled(
"Region:",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(region_display, Style::default().fg(Color::White)),
Span::styled(
identity_label,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} ⋮ ", identity_value),
Style::default().fg(Color::White),
),
Span::styled(
"Timestamp:",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} (UTC)", timestamp),
Style::default().fg(Color::White),
),
];
let info_widget = Paragraph::new(Line::from(info_spans))
.alignment(Alignment::Right)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
frame.render_widget(info_widget, chunks[0]);
let tab_data: Vec<(&str, bool)> = app
.tabs
.iter()
.enumerate()
.map(|(i, tab)| (tab.title.as_ref(), i == app.current_tab))
.collect();
let spans = render_tab_spans(&tab_data);
let tabs_widget = Paragraph::new(Line::from(spans));
frame.render_widget(tabs_widget, chunks[1]);
}
fn render_top_bar(frame: &mut Frame, app: &App, area: Rect) {
let breadcrumbs_str = app.breadcrumbs();
let breadcrumb_line = if app.current_service == Service::S3Buckets
&& app.s3_state.current_bucket.is_some()
&& !app.s3_state.prefix_stack.is_empty()
{
let parts: Vec<&str> = breadcrumbs_str.split(" › ").collect();
let mut spans = Vec::new();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" › "));
}
if i == parts.len() - 1 {
spans.push(Span::styled(
*part,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(*part));
}
}
Line::from(spans)
} else {
Line::from(breadcrumbs_str)
};
let breadcrumb_widget =
Paragraph::new(breadcrumb_line).style(Style::default().fg(Color::White));
frame.render_widget(breadcrumb_widget, area);
}
fn render_bottom_bar(frame: &mut Frame, app: &App, area: Rect) {
status::render_bottom_bar(frame, app, area);
}
fn render_service(frame: &mut Frame, app: &App, area: Rect) {
match app.current_service {
Service::CloudWatchLogGroups => {
if app.view_mode == ViewMode::Events {
cw::logs::render_events(frame, app, area);
} else if app.view_mode == ViewMode::Detail {
cw::logs::render_group_detail(frame, app, area);
} else {
cw::logs::render_groups_list(frame, app, area);
}
}
Service::CloudWatchInsights => cw::render_insights(frame, app, area),
Service::CloudWatchAlarms => cw::render_alarms(frame, app, area),
Service::CloudTrailEvents => cloudtrail::render_events(frame, app, area),
Service::Ec2Instances => {
if app.ec2_state.current_instance.is_some() {
ec2::render_instance_detail(frame, area, app);
} else {
ec2::render_instances(
frame,
area,
&app.ec2_state,
&app.ec2_visible_column_ids
.iter()
.map(|s| s.as_ref())
.collect::<Vec<_>>(),
app.mode,
);
}
}
Service::EcrRepositories => ecr::render_repositories(frame, app, area),
Service::LambdaFunctions => lambda::render_functions(frame, app, area),
Service::LambdaApplications => lambda::render_applications(frame, app, area),
Service::S3Buckets => s3::render_buckets(frame, app, area),
Service::SqsQueues => sqs::render_queues(frame, app, area),
Service::CloudFormationStacks => cfn::render_stacks(frame, app, area),
Service::IamUsers => iam::render_users(frame, app, area),
Service::IamRoles => iam::render_roles(frame, app, area),
Service::IamUserGroups => iam::render_user_groups(frame, app, area),
Service::ApiGatewayApis => apig::render_apis(frame, app, area),
}
}
fn render_column_selector(frame: &mut Frame, app: &App, area: Rect) {
let (items, title, max_text_len) = if app.current_service == Service::S3Buckets
&& app.s3_state.current_bucket.is_none()
{
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.s3_bucket_column_ids {
if let Some(col) = BucketColumn::from_id(col_id) {
let is_visible = app.s3_bucket_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.s3_state.buckets.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::CloudWatchAlarms {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cw_alarm_column_ids {
let is_visible = app.cw_alarm_visible_column_ids.contains(col_id);
if let Some(col) = AlarmColumn::from_id(col_id) {
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (header, header_len) = render_section_header("View as");
all_items.push(header);
max_len = max_len.max(header_len);
let (item, len) = render_radio_item(
"Table",
app.alarms_state.view_as == AlarmViewMode::Table,
true,
);
all_items.push(item);
max_len = max_len.max(len);
let (item, len) = render_radio_item(
"Cards",
app.alarms_state.view_as == AlarmViewMode::Cards,
true,
);
all_items.push(item);
max_len = max_len.max(len);
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.alarms_state.table.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
all_items.push(ListItem::new(""));
let (header, header_len) = render_section_header("Wrap lines");
all_items.push(header);
max_len = max_len.max(header_len);
let (item, len) = render_column_toggle_string("Wrap lines", app.alarms_state.wrap_lines);
all_items.push(item);
max_len = max_len.max(len);
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::CloudTrailEvents {
if app.cloudtrail_state.current_event.is_some()
&& app.cloudtrail_state.detail_focus == CloudTrailDetailFocus::Resources
{
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cloudtrail_resource_column_ids {
let is_visible = app.cloudtrail_resource_visible_column_ids.contains(col_id);
if let Some(col) = EventResourceColumn::from_id(col_id) {
let (item, len) = render_column_toggle_string(col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
(all_items, " Preferences ", max_len)
} else {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cloudtrail_event_column_ids {
let is_visible = app.cloudtrail_event_visible_column_ids.contains(col_id);
if let Some(col) = CloudTrailEventColumn::from_id(col_id) {
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.cloudtrail_state.table.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
}
} else if app.view_mode == ViewMode::Events
&& app.current_service == Service::CloudWatchLogGroups
{
let mut max_len = 0;
let items: Vec<ListItem> = app
.cw_log_event_column_ids
.iter()
.filter_map(|col_id| {
EventColumn::from_id(col_id).map(|col| {
let is_visible = app.cw_log_event_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(col.name(), is_visible);
max_len = max_len.max(len);
item
})
})
.collect();
(items, " Select visible columns (Space to toggle) ", max_len)
} else if app.view_mode == ViewMode::Detail
&& app.current_service == Service::CloudWatchLogGroups
{
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
if app.log_groups_state.detail_tab == DetailTab::Tags {
for col_id in &app.cw_log_tag_column_ids {
if let Some(col) = crate::cw::TagColumn::from_id(col_id) {
let is_visible = app.cw_log_tag_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.log_groups_state.tags.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else {
for col_id in &app.cw_log_stream_column_ids {
if let Some(col) = StreamColumn::from_id(col_id) {
let is_visible = app.cw_log_stream_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let page_size_enum = match app.log_groups_state.stream_page_size {
10 => PageSize::Ten,
25 => PageSize::TwentyFive,
50 => PageSize::Fifty,
_ => PageSize::OneHundred,
};
let (page_items, page_len) =
render_page_size_section(page_size_enum, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::CloudWatchLogGroups {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cw_log_group_column_ids {
if let Some(col) = LogGroupColumn::from_id(col_id) {
let is_visible = app.cw_log_group_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.log_groups_state.log_groups.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::ApiGatewayApis {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
if app.apig_state.current_api.is_none() {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.apig_api_column_ids {
use crate::apig::api::Column as ApigColumn;
if let Some(col) = ApigColumn::from_id(col_id) {
let is_visible = app.apig_api_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.apig_state.apis.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else if let Some(api) = &app.apig_state.current_api {
use crate::apig::route::Column as RouteColumn;
use crate::ui::apig::ApiDetailTab;
if app.apig_state.detail_tab == ApiDetailTab::Routes {
if api.protocol_type.to_uppercase() == "REST" {
use crate::apig::resource::Column as ResourceColumn;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for (i, col_id) in app.apig_resource_column_ids.iter().enumerate() {
if let Some(col) = ResourceColumn::from_id(col_id) {
if i == 0 {
let col_name = col.name().to_string();
let spans = vec![
Span::styled("◼", Style::default().fg(Color::DarkGray)),
Span::styled("⬜", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(
col_name.clone(),
Style::default().fg(Color::DarkGray),
),
];
let item = ListItem::new(Line::from(spans));
all_items.push(item);
max_len = max_len.max(4 + col_name.len());
} else {
let is_visible =
app.apig_resource_visible_column_ids.contains(col_id);
let (item, len) =
render_column_toggle_string(col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
}
} else {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for (i, col_id) in app.apig_route_column_ids.iter().enumerate() {
if let Some(col) = RouteColumn::from_id(col_id) {
if i == 0 {
let col_name = col.name().to_string();
let spans = vec![
Span::styled("◼", Style::default().fg(Color::DarkGray)),
Span::styled("⬜", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(
col_name.clone(),
Style::default().fg(Color::DarkGray),
),
];
let item = ListItem::new(Line::from(spans));
all_items.push(item);
max_len = max_len.max(4 + col_name.len());
} else {
let is_visible = app.apig_route_visible_column_ids.contains(col_id);
let (item, len) =
render_column_toggle_string(col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
}
}
}
}
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::EcrRepositories {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
if app.ecr_state.current_repository.is_some() {
for col_id in &app.ecr_image_column_ids {
if let Some(col) = image::Column::from_id(col_id) {
let is_visible = app.ecr_image_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.ecr_state.images.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else {
for col_id in &app.ecr_repo_column_ids {
if let Some(col) = repo::Column::from_id(col_id) {
let is_visible = app.ecr_repo_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.ecr_state.repositories.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::Ec2Instances {
if app.ec2_state.current_instance.is_some()
&& app.ec2_state.detail_tab == ec2::DetailTab::Tags
{
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.ec2_state.tag_column_ids {
use crate::ec2::tag::Column as TagColumn;
if let Some(col) = TagColumn::from_id(col_id) {
let is_visible = app.ec2_state.tag_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.ec2_state.tags.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.ec2_column_ids {
if let Some(col) = Ec2Column::from_id(col_id) {
let is_visible = app.ec2_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.ec2_state.table.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
}
} else if app.current_service == Service::SqsQueues {
if app.sqs_state.current_queue.is_some()
&& app.sqs_state.detail_tab == SqsQueueDetailTab::LambdaTriggers
{
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.sqs_state.trigger_column_ids {
if let Some(col) = SqsTriggerColumn::from_id(col_id) {
let is_visible = app.sqs_state.trigger_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.sqs_state.triggers.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else if app.sqs_state.current_queue.is_some()
&& app.sqs_state.detail_tab == SqsQueueDetailTab::SnsSubscriptions
{
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.sqs_state.subscription_column_ids {
if let Some(col) = SqsSubscriptionColumn::from_id(col_id) {
let is_visible = app
.sqs_state
.subscription_visible_column_ids
.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.sqs_state.subscriptions.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else if app.sqs_state.current_queue.is_some()
&& app.sqs_state.detail_tab == SqsQueueDetailTab::EventBridgePipes
{
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.sqs_state.pipe_column_ids {
if let Some(col) = SqsPipeColumn::from_id(col_id) {
let is_visible = app.sqs_state.pipe_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.sqs_state.pipes.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else if app.sqs_state.current_queue.is_some()
&& app.sqs_state.detail_tab == SqsQueueDetailTab::Tagging
{
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.sqs_state.tag_column_ids {
if let Some(col) = SqsTagColumn::from_id(col_id) {
let is_visible = app.sqs_state.tag_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.sqs_state.tags.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else if app.sqs_state.current_queue.is_none() {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.sqs_column_ids {
if let Some(col) = SqsColumn::from_id(col_id) {
let is_visible = app.sqs_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.sqs_state.queues.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else {
(vec![], " Preferences ", 0)
}
} else if app.current_service == Service::LambdaFunctions {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
if app.lambda_state.current_function.is_some()
&& app.lambda_state.detail_tab == LambdaDetailTab::Code
{
for col in &app.lambda_state.layer_column_ids {
let is_visible = app.lambda_state.layer_visible_column_ids.contains(col);
let (item, len) = render_column_toggle_string(col, is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
} else if app.lambda_state.detail_tab == LambdaDetailTab::Versions {
for col in &app.lambda_state.version_column_ids {
let is_visible = app.lambda_state.version_visible_column_ids.contains(col);
let (item, len) = render_column_toggle_string(col, is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
} else if app.lambda_state.detail_tab == LambdaDetailTab::Aliases {
for col in &app.lambda_state.alias_column_ids {
let is_visible = app.lambda_state.alias_visible_column_ids.contains(col);
let (item, len) = render_column_toggle_string(col, is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
} else {
for col_id in &app.lambda_state.function_column_ids {
if let Some(col) = FunctionColumn::from_id(col_id) {
let is_visible = app
.lambda_state
.function_visible_column_ids
.contains(col_id);
let (item, len) = render_column_toggle_string(col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
if app.lambda_state.detail_tab == LambdaDetailTab::Versions {
app.lambda_state.version_table.page_size
} else {
app.lambda_state.table.page_size
},
PAGE_SIZE_OPTIONS,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::LambdaApplications {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
if app.lambda_application_state.current_application.is_some() {
if app.lambda_application_state.detail_tab == ApplicationDetailTab::Overview {
for col_id in &app.lambda_resource_column_ids {
let is_visible = app.lambda_resource_visible_column_ids.contains(col_id);
if let Some(col) = ResourceColumn::from_id(col_id) {
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.lambda_application_state.resources.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else {
for col_id in &app.lambda_deployment_column_ids {
let is_visible = app.lambda_deployment_visible_column_ids.contains(col_id);
if let Some(col) = DeploymentColumn::from_id(col_id) {
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.lambda_application_state.deployments.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
} else {
for col_id in &app.lambda_application_column_ids {
if let Some(col) = ApplicationColumn::from_id(col_id) {
let is_visible = app.lambda_application_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.lambda_application_state.table.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::CloudFormationStacks {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
if app.cfn_state.current_stack.is_some()
&& app.cfn_state.detail_tab == CfnDetailTab::StackInfo
{
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
let tag_columns = ["Key", "Value"];
for col_name in &tag_columns {
let (item, len) = render_column_toggle_string(col_name, true);
all_items.push(item);
max_len = max_len.max(len);
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.cfn_state.tags.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else if app.cfn_state.current_stack.is_some()
&& app.cfn_state.detail_tab == CfnDetailTab::Parameters
{
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cfn_parameter_column_ids {
let is_visible = app.cfn_parameter_visible_column_ids.contains(col_id);
if let Some(col) = ParameterColumn::from_id(col_id) {
let name = translate_column(col.id(), col.default_name());
let (item, len) = render_column_toggle_string(&name, is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.cfn_state.parameters.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else if app.cfn_state.current_stack.is_some()
&& app.cfn_state.detail_tab == CfnDetailTab::Outputs
{
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cfn_output_column_ids {
let is_visible = app.cfn_output_visible_column_ids.contains(col_id);
if let Some(col) = OutputColumn::from_id(col_id) {
let name = translate_column(col.id(), col.default_name());
let (item, len) = render_column_toggle_string(&name, is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.cfn_state.outputs.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else if app.cfn_state.current_stack.is_some()
&& app.cfn_state.detail_tab == CfnDetailTab::Resources
{
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cfn_resource_column_ids {
let is_visible = app.cfn_resource_visible_column_ids.contains(col_id);
if let Some(col) = CfnResourceColumn::from_id(col_id) {
let name = translate_column(col.id(), col.default_name());
let (item, len) = render_column_toggle_string(&name, is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.cfn_state.resources.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else if app.cfn_state.current_stack.is_some()
&& app.cfn_state.detail_tab == CfnDetailTab::Events
{
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cfn_event_column_ids {
let is_visible = app.cfn_event_visible_column_ids.contains(col_id);
if let Some(col) = CfnEventColumn::from_id(col_id) {
let name = translate_column(col.id(), col.default_name());
let (item, len) = render_column_toggle_string(&name, is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.cfn_state.events.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else if app.cfn_state.current_stack.is_some()
&& app.cfn_state.detail_tab == CfnDetailTab::ChangeSets
{
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cfn_change_set_column_ids {
let is_visible = app.cfn_change_set_visible_column_ids.contains(col_id);
if let Some(col) = CfnChangeSetColumn::from_id(col_id) {
let name = translate_column(col.id(), col.default_name());
let (item, len) = render_column_toggle_string(&name, is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.cfn_state.change_sets.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
} else if app.cfn_state.current_stack.is_none() {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.cfn_column_ids {
let is_visible = app.cfn_visible_column_ids.contains(col_id);
if let Some(col) = CfnColumn::from_id(col_id) {
let (item, len) = render_column_toggle_string(&col.name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.cfn_state.table.page_size, PAGE_SIZE_OPTIONS);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::IamUsers {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
if app.iam_state.current_user.is_some() {
match app.iam_state.user_tab {
UserTab::Permissions => {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col in &app.iam_policy_column_ids {
let is_visible = app.iam_policy_visible_column_ids.contains(col);
let mut spans = vec![];
spans.extend(render_toggle(is_visible));
spans.push(Span::raw(" "));
spans.push(Span::raw(col.clone()));
let text_len = 4 + col.len();
all_items.push(ListItem::new(Line::from(spans)));
max_len = max_len.max(text_len);
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.iam_state.policies.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
UserTab::Groups => {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col in &["Group name", "Attached policies"] {
let mut spans = vec![];
spans.extend(render_toggle(true));
spans.push(Span::raw(" "));
spans.push(Span::raw(*col));
let text_len = 4 + col.len();
all_items.push(ListItem::new(Line::from(spans)));
max_len = max_len.max(text_len);
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.iam_state.user_group_memberships.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
UserTab::Tags => {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col in &["Key", "Value"] {
let mut spans = vec![];
spans.extend(render_toggle(true));
spans.push(Span::raw(" "));
spans.push(Span::raw(*col));
let text_len = 4 + col.len();
all_items.push(ListItem::new(Line::from(spans)));
max_len = max_len.max(text_len);
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.iam_state.user_tags.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
UserTab::LastAccessed => {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col in &["Service", "Policies granting", "Last accessed"] {
let mut spans = vec![];
spans.extend(render_toggle(true));
spans.push(Span::raw(" "));
spans.push(Span::raw(*col));
let text_len = 4 + col.len();
all_items.push(ListItem::new(Line::from(spans)));
max_len = max_len.max(text_len);
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.iam_state.last_accessed_services.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
_ => {}
}
} else if app.iam_state.current_user.is_none() {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.iam_user_column_ids {
if let Some(col) = UserColumn::from_id(col_id) {
let is_visible = app.iam_user_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(col.default_name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.iam_state.users.page_size, PAGE_SIZE_OPTIONS_SMALL);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::IamRoles {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
if app.iam_state.current_role.is_some() {
match app.iam_state.role_tab {
RoleTab::Permissions => {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col in &app.iam_policy_column_ids {
let is_visible = app.iam_policy_visible_column_ids.contains(col);
let mut spans = vec![];
spans.extend(render_toggle(is_visible));
spans.push(Span::raw(" "));
spans.push(Span::raw(col.clone()));
let text_len = 4 + col.len();
all_items.push(ListItem::new(Line::from(spans)));
max_len = max_len.max(text_len);
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.iam_state.policies.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
RoleTab::Tags => {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col in &["Key", "Value"] {
let mut spans = vec![];
spans.extend(render_toggle(true)); spans.push(Span::raw(" "));
spans.push(Span::raw(*col));
let text_len = 4 + col.len();
all_items.push(ListItem::new(Line::from(spans)));
max_len = max_len.max(text_len);
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.iam_state.tags.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
RoleTab::LastAccessed => {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col in &["Service", "Policies granting", "Last accessed"] {
let mut spans = vec![];
spans.extend(render_toggle(true));
spans.push(Span::raw(" "));
spans.push(Span::raw(*col));
let text_len = 4 + col.len();
all_items.push(ListItem::new(Line::from(spans)));
max_len = max_len.max(text_len);
}
all_items.push(ListItem::new(""));
let (page_items, page_len) = render_page_size_section(
app.iam_state.last_accessed_services.page_size,
PAGE_SIZE_OPTIONS_SMALL,
);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
_ => {
}
}
} else {
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col_id in &app.iam_role_column_ids {
if let Some(col) = RoleColumn::from_id(col_id) {
let is_visible = app.iam_role_visible_column_ids.contains(col_id);
let (item, len) = render_column_toggle_string(col.default_name(), is_visible);
all_items.push(item);
max_len = max_len.max(len);
}
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.iam_state.roles.page_size, PAGE_SIZE_OPTIONS_SMALL);
all_items.extend(page_items);
max_len = max_len.max(page_len);
}
(all_items, " Preferences ", max_len)
} else if app.current_service == Service::IamUserGroups {
let mut all_items: Vec<ListItem> = Vec::new();
let mut max_len = 0;
let (header, header_len) = render_section_header("Columns");
all_items.push(header);
max_len = max_len.max(header_len);
for col in &app.iam_group_column_ids {
let is_visible = app.iam_group_visible_column_ids.contains(col);
let mut spans = vec![];
spans.extend(render_toggle(is_visible));
spans.push(Span::raw(" "));
spans.push(Span::raw(col.clone()));
let text_len = 4 + col.len();
all_items.push(ListItem::new(Line::from(spans)));
max_len = max_len.max(text_len);
}
all_items.push(ListItem::new(""));
let (page_items, page_len) =
render_page_size_section(app.iam_state.groups.page_size, PAGE_SIZE_OPTIONS_SMALL);
all_items.extend(page_items);
max_len = max_len.max(page_len);
(all_items, " Preferences ", max_len)
} else {
(vec![], " Preferences ", 0)
};
let item_count = items.len();
let width = (max_text_len + 10).clamp(30, 100) as u16;
let height = (item_count as u16 + 2).max(8); let max_height = area.height.saturating_sub(4);
let actual_height = height.min(max_height);
let popup_area = centered_rect_absolute(width, actual_height, area);
let needs_scrollbar = height > max_height;
let border_color = Color::Green;
let list = List::new(items)
.block(
Block::default()
.title(format_title(title.trim()))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color)),
)
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("► ");
let mut state = ListState::default();
state.select(Some(app.column_selector_index));
frame.render_widget(Clear, popup_area);
frame.render_stateful_widget(list, popup_area, &mut state);
if needs_scrollbar {
render_scrollbar(
frame,
popup_area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
item_count,
app.column_selector_index,
);
}
}
fn render_error_modal(frame: &mut Frame, app: &App, area: Rect) {
let popup_area = centered_rect(80, 60, area);
frame.render_widget(Clear, popup_area);
frame.render_widget(
Block::default()
.title(format_title("Error"))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(red_text())
.style(Style::default().bg(Color::Black)),
popup_area,
);
let inner = popup_area.inner(Margin {
vertical: 1,
horizontal: 1,
});
let error_text = app.error_message.as_deref().unwrap_or("Unknown error");
let chunks = vertical(
[
Constraint::Length(2), Constraint::Min(0), Constraint::Length(2), ],
inner,
);
let header = Paragraph::new("AWS Error")
.alignment(Alignment::Center)
.style(red_text().add_modifier(Modifier::BOLD));
frame.render_widget(header, chunks[0]);
let error_lines: Vec<Line> = error_text
.lines()
.skip(app.error_scroll)
.flat_map(|line| {
let width = chunks[1].width.saturating_sub(4) as usize; if line.len() <= width {
vec![Line::from(line)]
} else {
line.chars()
.collect::<Vec<_>>()
.chunks(width)
.map(|chunk| Line::from(chunk.iter().collect::<String>()))
.collect()
}
})
.collect();
let error_paragraph = Paragraph::new(error_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(active_border()),
)
.style(Style::default().fg(Color::White));
frame.render_widget(error_paragraph, chunks[1]);
let total_lines: usize = error_text
.lines()
.map(|line| {
let width = chunks[1].width.saturating_sub(4) as usize;
if line.len() <= width {
1
} else {
line.len().div_ceil(width)
}
})
.sum();
let visible_lines = chunks[1].height.saturating_sub(2) as usize;
if total_lines > visible_lines {
render_scrollbar(
frame,
chunks[1].inner(Margin {
vertical: 1,
horizontal: 0,
}),
total_lines,
app.error_scroll,
);
}
let help_spans = vec![
first_hint("^r", "retry"),
hint("y", "copy"),
hint("↑↓,^u,^d", "scroll"),
last_hint("q,⎋", "close"),
]
.into_iter()
.flatten()
.collect::<Vec<_>>();
let help = Paragraph::new(Line::from(help_spans)).alignment(Alignment::Center);
frame.render_widget(help, chunks[2]);
}
fn render_space_menu(frame: &mut Frame, area: Rect) {
let items = vec![
Line::from(vec![
Span::styled("o", Style::default().fg(Color::Yellow)),
Span::raw(" services"),
]),
Line::from(vec![
Span::styled("t", Style::default().fg(Color::Yellow)),
Span::raw(" tabs"),
]),
Line::from(vec![
Span::styled("c", Style::default().fg(Color::Yellow)),
Span::raw(" close"),
]),
Line::from(vec![
Span::styled("r", Style::default().fg(Color::Yellow)),
Span::raw(" regions"),
]),
Line::from(vec![
Span::styled("s", Style::default().fg(Color::Yellow)),
Span::raw(" sessions"),
]),
Line::from(vec![
Span::styled("h", Style::default().fg(Color::Yellow)),
Span::raw(" help"),
]),
];
let menu_height = items.len() as u16 + 2; let menu_area = bottom_right_rect(30, menu_height, area);
let paragraph = Paragraph::new(items)
.block(
Block::default()
.title(format_title("Menu"))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(active_border()),
)
.style(Style::default().bg(Color::Black));
frame.render_widget(Clear, menu_area);
frame.render_widget(paragraph, menu_area);
}
fn render_service_picker(frame: &mut Frame, app: &App, area: Rect) {
let popup_area = centered_rect(60, 60, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(popup_area);
let is_active = app.mode == Mode::ServicePicker;
let filter_text = if app.service_picker.filter_active {
vec![
Span::raw(&app.service_picker.filter),
Span::styled("█", Style::default().fg(Color::Green)),
]
} else {
vec![Span::raw(&app.service_picker.filter)]
};
let active_color = Color::Green;
let inactive_color = Color::White;
let filter = Paragraph::new(Line::from(filter_text))
.block(
Block::default()
.title(SEARCH_ICON)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(if app.service_picker.filter_active {
active_color
} else {
inactive_color
})),
)
.style(Style::default());
let filtered = app.filtered_services();
let items: Vec<ListItem> = filtered.iter().map(|s| ListItem::new(*s)).collect();
let list = List::new(items)
.block(
Block::default()
.title(format_title("AWS Services"))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(if is_active && !app.service_picker.filter_active {
active_border()
} else {
Style::default().fg(Color::White)
}),
)
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("► ");
let mut state = ListState::default();
state.select(Some(app.service_picker.selected));
frame.render_widget(Clear, popup_area);
frame.render_widget(filter, chunks[0]);
frame.render_stateful_widget(list, chunks[1], &mut state);
}
fn render_tab_picker(frame: &mut Frame, app: &App, area: Rect) {
let popup_area = centered_rect(80, 60, area);
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(popup_area);
let filter_text = if app.tab_filter.is_empty() {
"Type to filter tabs...".to_string()
} else {
app.tab_filter.clone()
};
let filter_style = if app.tab_filter.is_empty() {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
let filter = Paragraph::new(filter_text).style(filter_style).block(
Block::default()
.title(SEARCH_ICON)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Yellow)),
);
frame.render_widget(Clear, main_chunks[0]);
frame.render_widget(filter, main_chunks[0]);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[1]);
let filtered_tabs = app.get_filtered_tabs();
let items: Vec<ListItem> = filtered_tabs
.iter()
.map(|(_, tab)| ListItem::new(tab.breadcrumb.clone()))
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format_title(&format!(
"Tabs ({}/{})",
filtered_tabs.len(),
app.tabs.len()
)))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(active_border()),
)
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("► ");
let mut state = ListState::default();
state.select(Some(app.tab_picker_selected));
frame.render_widget(Clear, chunks[0]);
frame.render_stateful_widget(list, chunks[0], &mut state);
frame.render_widget(Clear, chunks[1]);
let preview_block = Block::default()
.title(format_title("Preview"))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan));
let preview_inner = preview_block.inner(chunks[1]);
frame.render_widget(preview_block, chunks[1]);
if let Some(&(_, tab)) = filtered_tabs.get(app.tab_picker_selected) {
render_service_preview(frame, app, tab.service, preview_inner);
}
}
fn render_service_preview(frame: &mut Frame, app: &App, service: Service, area: Rect) {
match service {
Service::CloudWatchLogGroups => {
if app.view_mode == ViewMode::Events {
cw::logs::render_events(frame, app, area);
} else if app.view_mode == ViewMode::Detail {
cw::logs::render_group_detail(frame, app, area);
} else {
cw::logs::render_groups_list(frame, app, area);
}
}
Service::CloudWatchInsights => cw::render_insights(frame, app, area),
Service::CloudWatchAlarms => cw::render_alarms(frame, app, area),
Service::CloudTrailEvents => cloudtrail::render_events(frame, app, area),
Service::Ec2Instances => {
if app.ec2_state.current_instance.is_some() {
ec2::render_instance_detail(frame, area, app);
} else {
ec2::render_instances(
frame,
area,
&app.ec2_state,
&app.ec2_visible_column_ids
.iter()
.map(|s| s.as_ref())
.collect::<Vec<_>>(),
app.mode,
);
}
}
Service::EcrRepositories => ecr::render_repositories(frame, app, area),
Service::LambdaFunctions => lambda::render_functions(frame, app, area),
Service::LambdaApplications => lambda::render_applications(frame, app, area),
Service::S3Buckets => s3::render_buckets(frame, app, area),
Service::SqsQueues => sqs::render_queues(frame, app, area),
Service::CloudFormationStacks => cfn::render_stacks(frame, app, area),
Service::IamUsers => iam::render_users(frame, app, area),
Service::IamRoles => iam::render_roles(frame, app, area),
Service::IamUserGroups => iam::render_user_groups(frame, app, area),
Service::ApiGatewayApis => apig::render_apis(frame, app, area),
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn centered_rect_absolute(width: u16, height: u16, r: Rect) -> Rect {
let x = (r.width.saturating_sub(width)) / 2;
let y = (r.height.saturating_sub(height)) / 2;
Rect {
x: r.x + x,
y: r.y + y,
width: width.min(r.width),
height: height.min(r.height),
}
}
fn bottom_right_rect(width: u16, height: u16, r: Rect) -> Rect {
let x = r.width.saturating_sub(width + 1);
let y = r.height.saturating_sub(height + 1);
Rect {
x: r.x + x,
y: r.y + y,
width: width.min(r.width),
height: height.min(r.height),
}
}
fn render_help_modal(frame: &mut Frame, area: Rect) {
let help_text = vec![
Line::from(vec![Span::styled("⎋ ", red_text()), Span::raw(" Escape")]),
Line::from(vec![
Span::styled("⏎ ", red_text()),
Span::raw(" Enter/Return"),
]),
Line::from(vec![Span::styled("⇤⇥ ", red_text()), Span::raw(" Tab")]),
Line::from(vec![Span::styled("␣ ", red_text()), Span::raw(" Space")]),
Line::from(vec![Span::styled("^r ", red_text()), Span::raw(" Ctrl+r")]),
Line::from(vec![Span::styled("^w ", red_text()), Span::raw(" Ctrl+w")]),
Line::from(vec![Span::styled("^o ", red_text()), Span::raw(" Ctrl+o")]),
Line::from(vec![Span::styled("^p ", red_text()), Span::raw(" Ctrl+p")]),
Line::from(vec![
Span::styled("^u ", red_text()),
Span::raw(" Ctrl+u (page up)"),
]),
Line::from(vec![
Span::styled("^d ", red_text()),
Span::raw(" Ctrl+d (page down)"),
]),
Line::from(vec![
Span::styled("[] ", red_text()),
Span::raw(" [ and ] (switch tabs)"),
]),
Line::from(vec![
Span::styled("↑↓ ", red_text()),
Span::raw(" Arrow up/down"),
]),
Line::from(vec![
Span::styled("←→ ", red_text()),
Span::raw(" Arrow left/right"),
]),
Line::from(""),
Line::from(vec![
Span::styled("Press ", Style::default()),
Span::styled("⎋", red_text()),
Span::styled(" or ", Style::default()),
Span::styled("⏎", red_text()),
Span::styled(" to close", Style::default()),
]),
];
let max_width = help_text
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.len())
.sum::<usize>()
})
.max()
.unwrap_or(80) as u16;
let content_width = max_width + 6; let content_height = help_text.len() as u16 + 2;
let popup_width = content_width.min(area.width.saturating_sub(4));
let popup_height = content_height.min(area.height.saturating_sub(4));
let popup_area = Rect {
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
y: area.y + (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
let paragraph = Paragraph::new(help_text)
.block(
Block::default()
.title(Span::styled(
" Help ",
Style::default().add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(active_border())
.padding(Padding::horizontal(1)),
)
.wrap(Wrap { trim: false });
frame.render_widget(Clear, popup_area);
frame.render_widget(paragraph, popup_area);
}
fn render_region_selector(frame: &mut Frame, app: &App, area: Rect) {
let popup_area = centered_rect(60, 60, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(popup_area);
let filter_text = if app.region_filter_active {
vec![
Span::from(&app.region_filter),
Span::styled("█", Style::default().fg(Color::Green)),
]
} else {
vec![Span::from(&app.region_filter)]
};
let filter = filter_area(filter_text, app.region_filter_active);
let filtered = app.get_filtered_regions();
let items: Vec<ListItem> = filtered
.iter()
.map(|r| {
let latency_str = match r.latency_ms {
Some(ms) => format!(" ({}ms)", ms),
None => String::new(),
};
let opt_in = if r.opt_in { "[opt-in] " } else { "" };
let display = format!(
"{} › {} › {} {}{}",
r.group, r.name, r.code, opt_in, latency_str
);
ListItem::new(display)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format_title("Regions"))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(if !app.region_filter_active {
active_border()
} else {
Style::default()
}),
)
.highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White))
.highlight_symbol("▶ ");
frame.render_widget(Clear, popup_area);
frame.render_widget(filter, chunks[0]);
frame.render_stateful_widget(
list,
chunks[1],
&mut ratatui::widgets::ListState::default().with_selected(Some(app.region_picker_selected)),
);
}
fn render_profile_picker(frame: &mut Frame, app: &App, area: Rect) {
crate::aws::render_profile_picker(frame, app, area, centered_rect);
}
fn render_session_picker(frame: &mut Frame, app: &App, area: Rect) {
crate::session::render_session_picker(frame, app, area, centered_rect);
}
fn render_calendar_picker(frame: &mut Frame, app: &App, area: Rect) {
use ratatui::widgets::calendar::{CalendarEventStore, Monthly};
let popup_area = centered_rect(50, 50, area);
let date = app
.calendar_date
.unwrap_or_else(|| time::OffsetDateTime::now_utc().date());
let field_name = match app.calendar_selecting {
CalendarField::StartDate => "Start Date",
CalendarField::EndDate => "End Date",
};
let events = CalendarEventStore::today(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Blue),
);
let calendar = Monthly::new(date, events)
.block(
Block::default()
.title(format_title(&format!("Select {}", field_name)))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_type(BorderType::Rounded)
.border_style(active_border()),
)
.show_weekdays_header(Style::new().bold().yellow())
.show_month_header(Style::new().bold().green());
frame.render_widget(Clear, popup_area);
frame.render_widget(calendar, popup_area);
}
fn highlight_yaml_line(line: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
if indent > 0 {
spans.push(Span::raw(" ".repeat(indent)));
}
if trimmed.is_empty() {
return spans;
}
if trimmed.starts_with('#') {
spans.push(Span::styled(
trimmed.to_string(),
Style::default().fg(Color::DarkGray),
));
return spans;
}
if trimmed == "---" || trimmed == "..." {
spans.push(Span::styled(
trimmed.to_string(),
Style::default().fg(Color::DarkGray),
));
return spans;
}
let (prefix, rest) = if let Some(stripped) = trimmed.strip_prefix("- ") {
(Some("- "), stripped)
} else {
(None, trimmed)
};
if let Some(pfx) = prefix {
spans.push(Span::styled(
pfx.to_string(),
Style::default().fg(Color::Cyan),
));
spans.extend(highlight_yaml_value_or_key(rest));
return spans;
}
spans.extend(highlight_yaml_value_or_key(rest));
spans
}
fn highlight_yaml_value_or_key(text: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if let Some(colon_pos) = text.find(':') {
let after_colon = &text[colon_pos + 1..];
if after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t') {
let key = &text[..colon_pos];
let key_display = key.trim_matches('"').trim_matches('\'');
spans.push(Span::styled(
key_display.to_string(),
Style::default().fg(Color::Blue),
));
spans.push(Span::raw(":".to_string()));
if after_colon.is_empty() {
return spans;
}
let value = after_colon.trim_start();
spans.push(Span::raw(" ".to_string()));
spans.extend(highlight_yaml_scalar(value));
return spans;
}
}
spans.extend(highlight_yaml_scalar(text));
spans
}
fn highlight_yaml_scalar(value: &str) -> Vec<Span<'static>> {
let trimmed = value.trim();
let (val_part, comment_part) = if let Some(pos) = trimmed.find(" #") {
(&trimmed[..pos], Some(&trimmed[pos..]))
} else {
(trimmed, None)
};
let val_span = if val_part == "true" || val_part == "false" || val_part == "~" {
Span::styled(val_part.to_string(), Style::default().fg(Color::Yellow))
} else if val_part == "null" {
Span::styled(val_part.to_string(), Style::default().fg(Color::DarkGray))
} else if val_part.starts_with('"') || val_part.starts_with('\'') {
Span::styled(val_part.to_string(), Style::default().fg(Color::Green))
} else if val_part
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit() || c == '-')
&& val_part
.chars()
.skip(1)
.all(|c| c.is_ascii_digit() || c == '.')
{
Span::styled(val_part.to_string(), Style::default().fg(Color::Magenta))
} else if val_part.starts_with('|') || val_part.starts_with('>') {
Span::styled(val_part.to_string(), Style::default().fg(Color::Cyan))
} else if val_part.starts_with('!') || val_part.starts_with('&') || val_part.starts_with('*') {
Span::styled(val_part.to_string(), Style::default().fg(Color::Magenta))
} else {
Span::raw(val_part.to_string())
};
let mut spans = vec![val_span];
if let Some(comment) = comment_part {
spans.push(Span::styled(
comment.to_string(),
Style::default().fg(Color::DarkGray),
));
}
spans
}
pub fn render_yaml_highlighted(
frame: &mut Frame,
area: Rect,
yaml_text: &str,
scroll_offset: usize,
title: &str,
is_active: bool,
) {
let total_lines = yaml_text.lines().count();
let line_num_width = total_lines.to_string().len().max(2);
let lines: Vec<Line> = yaml_text
.lines()
.enumerate()
.skip(scroll_offset)
.map(|(idx, line)| {
let mut spans = Vec::new();
let line_num = format!("{:>width$} │ ", idx + 1, width = line_num_width);
spans.push(Span::styled(line_num, Style::default().fg(Color::DarkGray)));
spans.extend(highlight_yaml_line(line));
Line::from(spans)
})
.collect();
let block = titled_block(title).border_style(if is_active {
active_border()
} else {
Style::default()
});
frame.render_widget(Paragraph::new(lines).block(block), area);
if total_lines > 0 {
render_scrollbar(
frame,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
total_lines,
scroll_offset,
);
}
}
pub fn render_template_highlighted(
frame: &mut Frame,
area: Rect,
text: &str,
scroll_offset: usize,
title: &str,
is_active: bool,
) {
let trimmed = text.trim_start();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
render_json_highlighted(frame, area, text, scroll_offset, title, is_active);
} else {
render_yaml_highlighted(frame, area, text, scroll_offset, title, is_active);
}
}
pub fn render_json_highlighted(
frame: &mut Frame,
area: Rect,
json_text: &str,
scroll_offset: usize,
title: &str,
is_active: bool,
) {
let total_lines = json_text.lines().count();
let line_num_width = total_lines.to_string().len().max(2);
let lines: Vec<Line> = json_text
.lines()
.enumerate()
.skip(scroll_offset)
.map(|(idx, line)| {
let mut spans = Vec::new();
let line_num = format!("{:>width$} │ ", idx + 1, width = line_num_width);
spans.push(Span::styled(line_num, Style::default().fg(Color::DarkGray)));
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
if indent > 0 {
spans.push(Span::raw(" ".repeat(indent)));
}
if trimmed.starts_with('"') && trimmed.contains(':') {
if let Some(colon_pos) = trimmed.find(':') {
spans.push(Span::styled(
&trimmed[..colon_pos],
Style::default().fg(Color::Blue),
));
spans.push(Span::raw(&trimmed[colon_pos..]));
} else {
spans.push(Span::raw(trimmed));
}
} else if trimmed.starts_with('"') {
spans.push(Span::styled(trimmed, Style::default().fg(Color::Green)));
} else if trimmed.starts_with("true") || trimmed.starts_with("false") {
spans.push(Span::styled(trimmed, Style::default().fg(Color::Yellow)));
} else if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
spans.push(Span::styled(trimmed, Style::default().fg(Color::Magenta)));
} else {
spans.push(Span::raw(trimmed));
}
Line::from(spans)
})
.collect();
let block = titled_block(title).border_style(if is_active {
active_border()
} else {
Style::default()
});
frame.render_widget(Paragraph::new(lines).block(block), area);
if total_lines > 0 {
render_scrollbar(
frame,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
total_lines,
scroll_offset,
);
}
}
pub fn render_tags_section<F>(frame: &mut Frame, area: Rect, render_table: F)
where
F: FnOnce(&mut Frame, Rect),
{
render_table(frame, area);
}
pub fn render_permissions_section<F>(
frame: &mut Frame,
area: Rect,
_description: &str,
render_table: F,
) where
F: FnOnce(&mut Frame, Rect),
{
render_table(frame, area);
}
pub fn render_last_accessed_section<F>(
frame: &mut Frame,
area: Rect,
_description: &str,
_note: &str,
render_table: F,
) where
F: FnOnce(&mut Frame, Rect),
{
render_table(frame, area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::Service;
use crate::app::Tab;
use crate::ecr::image::Image as EcrImage;
use crate::ecr::repo::Repository as EcrRepository;
use crate::keymap::Action;
use crate::lambda;
use crate::ui::cw::logs::filtered_log_groups;
use crate::ui::table::Column;
fn test_app() -> App {
App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
}
fn test_app_no_region() -> App {
App::new_without_client("test".to_string(), None)
}
#[test]
fn test_expanded_content_wrapping_marks_continuation_lines() {
let max_width = 50;
let col_name = "Message: ";
let value = "This is a very long message that will definitely exceed the maximum width and need to be wrapped";
let full_line = format!("{}{}", col_name, value);
let mut lines = Vec::new();
if full_line.len() <= max_width {
lines.push((full_line, true));
} else {
let first_chunk_len = max_width.min(full_line.len());
lines.push((full_line[..first_chunk_len].to_string(), true));
let mut remaining = &full_line[first_chunk_len..];
while !remaining.is_empty() {
let take = max_width.min(remaining.len());
lines.push((remaining[..take].to_string(), false));
remaining = &remaining[take..];
}
}
assert!(lines[0].1);
assert!(!lines[1].1);
assert!(lines.len() > 1);
}
#[test]
fn test_expanded_content_short_line_not_wrapped() {
let max_width = 100;
let col_name = "Timestamp: ";
let value = "2025-03-13 19:49:30 (UTC)";
let full_line = format!("{}{}", col_name, value);
let mut lines = Vec::new();
if full_line.len() <= max_width {
lines.push((full_line.clone(), true));
} else {
let first_chunk_len = max_width.min(full_line.len());
lines.push((full_line[..first_chunk_len].to_string(), true));
let mut remaining = &full_line[first_chunk_len..];
while !remaining.is_empty() {
let take = max_width.min(remaining.len());
lines.push((remaining[..take].to_string(), false));
remaining = &remaining[take..];
}
}
assert_eq!(lines.len(), 1);
assert!(lines[0].1);
assert_eq!(lines[0].0, full_line);
}
#[test]
fn test_tabs_display_with_separator() {
let tabs = [
Tab {
service: Service::CloudWatchLogGroups,
title: "CloudWatch › Log Groups".to_string(),
breadcrumb: "CloudWatch › Log Groups".to_string(),
},
Tab {
service: Service::CloudWatchInsights,
title: "CloudWatch › Logs Insights".to_string(),
breadcrumb: "CloudWatch › Logs Insights".to_string(),
},
];
let mut spans = Vec::new();
for (i, tab) in tabs.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ⋮ "));
}
spans.push(Span::raw(tab.title.clone()));
}
assert_eq!(spans.len(), 3);
assert_eq!(spans[1].content, " ⋮ ");
}
#[test]
fn test_current_tab_highlighted() {
let tabs = [
crate::app::Tab {
service: Service::CloudWatchLogGroups,
title: "CloudWatch › Log Groups".to_string(),
breadcrumb: "CloudWatch › Log Groups".to_string(),
},
crate::app::Tab {
service: Service::CloudWatchInsights,
title: "CloudWatch › Logs Insights".to_string(),
breadcrumb: "CloudWatch › Logs Insights".to_string(),
},
];
let current_tab = 1;
let mut spans = Vec::new();
for (i, tab) in tabs.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ⋮ "));
}
if i == current_tab {
spans.push(Span::styled(
tab.title.clone(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(tab.title.clone()));
}
}
assert_eq!(spans[2].style.fg, Some(Color::Yellow));
assert!(spans[2].style.add_modifier.contains(Modifier::BOLD));
assert_eq!(spans[0].style.fg, None);
}
#[test]
fn test_lambda_application_update_complete_shows_green_checkmark() {
let app = crate::lambda::Application {
name: "test-stack".to_string(),
arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
.to_string(),
description: "Test stack".to_string(),
status: "UPDATE_COMPLETE".to_string(),
last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
};
let col = ApplicationColumn::Status;
let (text, style) = col.render(&app);
assert_eq!(text, "✅ UPDATE_COMPLETE");
assert_eq!(style.fg, Some(Color::Green));
}
#[test]
fn test_lambda_application_create_complete_shows_green_checkmark() {
let app = crate::lambda::Application {
name: "test-stack".to_string(),
arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
.to_string(),
description: "Test stack".to_string(),
status: "CREATE_COMPLETE".to_string(),
last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
};
let col = ApplicationColumn::Status;
let (text, style) = col.render(&app);
assert_eq!(text, "✅ CREATE_COMPLETE");
assert_eq!(style.fg, Some(Color::Green));
}
#[test]
fn test_lambda_application_other_status_shows_default() {
let app = crate::lambda::Application {
name: "test-stack".to_string(),
arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
.to_string(),
description: "Test stack".to_string(),
status: "UPDATE_IN_PROGRESS".to_string(),
last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
};
let col = ApplicationColumn::Status;
let (text, style) = col.render(&app);
assert_eq!(text, "ℹ️ UPDATE_IN_PROGRESS");
assert_eq!(style.fg, Some(ratatui::style::Color::LightBlue));
}
#[test]
fn test_lambda_application_status_complete() {
let app = crate::lambda::Application {
name: "test-stack".to_string(),
arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
.to_string(),
description: "Test stack".to_string(),
status: "UPDATE_COMPLETE".to_string(),
last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
};
let col = ApplicationColumn::Status;
let (text, style) = col.render(&app);
assert_eq!(text, "✅ UPDATE_COMPLETE");
assert_eq!(style.fg, Some(ratatui::style::Color::Green));
}
#[test]
fn test_lambda_application_status_failed() {
let app = crate::lambda::Application {
name: "test-stack".to_string(),
arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
.to_string(),
description: "Test stack".to_string(),
status: "UPDATE_FAILED".to_string(),
last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
};
let col = ApplicationColumn::Status;
let (text, style) = col.render(&app);
assert_eq!(text, "❌ UPDATE_FAILED");
assert_eq!(style.fg, Some(ratatui::style::Color::Red));
}
#[test]
fn test_lambda_application_status_rollback() {
let app = crate::lambda::Application {
name: "test-stack".to_string(),
arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
.to_string(),
description: "Test stack".to_string(),
status: "UPDATE_ROLLBACK_IN_PROGRESS".to_string(),
last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
};
let col = ApplicationColumn::Status;
let (text, style) = col.render(&app);
assert_eq!(text, "❌ UPDATE_ROLLBACK_IN_PROGRESS");
assert_eq!(style.fg, Some(ratatui::style::Color::Red));
}
#[test]
fn test_tab_picker_shows_breadcrumb_and_preview() {
let tabs = [
crate::app::Tab {
service: crate::app::Service::CloudWatchLogGroups,
title: "CloudWatch › Log Groups".to_string(),
breadcrumb: "CloudWatch › Log Groups".to_string(),
},
crate::app::Tab {
service: crate::app::Service::CloudWatchAlarms,
title: "CloudWatch > Alarms".to_string(),
breadcrumb: "CloudWatch > Alarms".to_string(),
},
];
let selected_idx = 1;
let selected_tab = &tabs[selected_idx];
assert_eq!(selected_tab.breadcrumb, "CloudWatch > Alarms");
assert_eq!(selected_tab.title, "CloudWatch > Alarms");
assert!(selected_tab.breadcrumb.contains("CloudWatch"));
assert!(selected_tab.breadcrumb.contains("Alarms"));
}
#[test]
fn test_tab_picker_has_active_border() {
let border_style = Style::default().fg(Color::Green);
let border_type = BorderType::Plain;
assert_eq!(border_style.fg, Some(Color::Green));
assert_eq!(border_type, BorderType::Plain);
}
#[test]
fn test_tab_picker_title_is_tabs() {
let title = " Tabs ";
assert_eq!(title.trim(), "Tabs");
assert!(!title.contains("Open"));
}
#[test]
fn test_s3_bucket_tabs_no_count_in_tabs() {
let general_purpose_tab = "General purpose buckets (All AWS Regions)";
let directory_tab = "Directory buckets";
assert!(!general_purpose_tab.contains("(0)"));
assert!(!general_purpose_tab.contains("(1)"));
assert!(!directory_tab.contains("(0)"));
assert!(!directory_tab.contains("(1)"));
let table_title = " General purpose buckets (42) ";
assert!(table_title.contains("(42)"));
}
#[test]
fn test_s3_bucket_column_preferences_shows_bucket_columns() {
use crate::app::S3BucketColumn;
let app = test_app();
assert_eq!(app.s3_bucket_column_ids.len(), 3);
assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
assert_eq!(S3BucketColumn::Name.name(), "Name");
assert_eq!(S3BucketColumn::Region.name(), "Region");
assert_eq!(S3BucketColumn::CreationDate.name(), "Creation date");
}
#[test]
fn test_s3_bucket_columns_not_cloudwatch_columns() {
let app = test_app();
let bucket_col_names: Vec<String> = app
.s3_bucket_column_ids
.iter()
.filter_map(|id| BucketColumn::from_id(id).map(|c| c.name()))
.collect();
let log_col_names: Vec<String> = app
.cw_log_group_column_ids
.iter()
.filter_map(|id| LogGroupColumn::from_id(id).map(|c| c.name().to_string()))
.collect();
assert_ne!(bucket_col_names, log_col_names);
assert!(!bucket_col_names.contains(&"Log group".to_string()));
assert!(!bucket_col_names.contains(&"Stored bytes".to_string()));
assert!(bucket_col_names.contains(&"Creation date".to_string()));
assert!(!bucket_col_names.contains(&"AWS Region".to_string()));
}
#[test]
fn test_s3_bucket_column_toggle() {
use crate::app::Service;
let mut app = test_app();
app.current_service = Service::S3Buckets;
assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
let col = app.s3_bucket_column_ids[1];
if let Some(pos) = app
.s3_bucket_visible_column_ids
.iter()
.position(|c| *c == col)
{
app.s3_bucket_visible_column_ids.remove(pos);
}
assert_eq!(app.s3_bucket_visible_column_ids.len(), 2);
assert!(!app
.s3_bucket_visible_column_ids
.contains(&"column.s3.bucket.region"));
app.s3_bucket_visible_column_ids.push(col);
assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
assert!(app
.s3_bucket_visible_column_ids
.contains(&"column.s3.bucket.region"));
}
#[test]
fn test_s3_preferences_dialog_title() {
let title = " Preferences ";
assert_eq!(title.trim(), "Preferences");
assert!(!title.contains("Space"));
assert!(!title.contains("toggle"));
}
#[test]
fn test_column_selector_mode_has_hotkey_hints() {
let help = " ↑↓: scroll | ␣: toggle | esc: close ";
assert!(help.contains("␣: toggle"));
assert!(help.contains("↑↓: scroll"));
assert!(help.contains("esc: close"));
assert!(!help.contains("⏎"));
assert!(!help.contains("^w"));
}
#[test]
fn test_date_range_title_no_hints() {
let title = " Date range ";
assert!(!title.contains("Tab to switch"));
assert!(!title.contains("Space to change"));
assert!(!title.contains("("));
assert!(!title.contains(")"));
}
#[test]
fn test_event_filter_mode_has_hints_in_status_bar() {
let help = " tab: switch | ␣: change unit | enter: apply | esc: cancel | ctrl+w: close ";
assert!(help.contains("tab: switch"));
assert!(help.contains("␣: change unit"));
assert!(help.contains("enter: apply"));
assert!(help.contains("esc: cancel"));
}
#[test]
fn test_s3_preferences_shows_all_columns() {
let app = test_app();
assert_eq!(app.s3_bucket_column_ids.len(), 3);
assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
let names: Vec<String> = app
.s3_bucket_column_ids
.iter()
.filter_map(|id| BucketColumn::from_id(id).map(|c| c.name()))
.collect();
assert_eq!(names, vec!["Name", "Region", "Creation date"]);
}
#[test]
fn test_s3_preferences_has_active_border() {
use ratatui::style::Color;
let border_color = Color::Green;
assert_eq!(border_color, Color::Green);
assert_ne!(border_color, Color::Cyan);
}
#[test]
fn test_s3_table_loses_focus_when_preferences_shown() {
use crate::app::Service;
use crate::keymap::Mode;
use ratatui::style::Color;
let mut app = test_app();
app.current_service = Service::S3Buckets;
app.mode = Mode::Normal;
let is_active = app.mode != Mode::ColumnSelector;
let border_color = if is_active {
Color::Green
} else {
Color::White
};
assert_eq!(border_color, Color::Green);
app.mode = Mode::ColumnSelector;
let is_active = app.mode != Mode::ColumnSelector;
let border_color = if is_active {
Color::Green
} else {
Color::White
};
assert_eq!(border_color, Color::White);
}
#[test]
fn test_s3_object_tabs_cleared_before_render() {
}
#[test]
fn test_s3_properties_tab_shows_bucket_info() {
use crate::app::{S3ObjectTab, Service};
let mut app = test_app();
app.current_service = Service::S3Buckets;
app.s3_state.current_bucket = Some("test-bucket".to_string());
app.s3_state.object_tab = S3ObjectTab::Properties;
assert_eq!(app.s3_state.object_tab, S3ObjectTab::Properties);
assert_eq!(app.s3_state.properties_scroll, 0);
}
#[test]
fn test_s3_properties_scrolling() {
use crate::app::{S3ObjectTab, Service};
let mut app = test_app();
app.current_service = Service::S3Buckets;
app.s3_state.current_bucket = Some("test-bucket".to_string());
app.s3_state.object_tab = S3ObjectTab::Properties;
assert_eq!(app.s3_state.properties_scroll, 0);
app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_add(1);
assert_eq!(app.s3_state.properties_scroll, 1);
app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_add(1);
assert_eq!(app.s3_state.properties_scroll, 2);
app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
assert_eq!(app.s3_state.properties_scroll, 1);
app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
assert_eq!(app.s3_state.properties_scroll, 0);
app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
assert_eq!(app.s3_state.properties_scroll, 0);
}
#[test]
fn test_s3_parent_prefix_cleared_before_render() {
}
#[test]
fn test_s3_empty_region_defaults_to_us_east_1() {
let _app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
let empty_region = "";
let bucket_region = if empty_region.is_empty() {
"us-east-1"
} else {
empty_region
};
assert_eq!(bucket_region, "us-east-1");
let set_region = "us-west-2";
let bucket_region = if set_region.is_empty() {
"us-east-1"
} else {
set_region
};
assert_eq!(bucket_region, "us-west-2");
}
#[test]
fn test_s3_properties_has_multiple_blocks() {
let block_count = 12;
assert_eq!(block_count, 12);
}
#[test]
fn test_s3_properties_tables_use_common_component() {
let tags_columns = ["Key", "Value"];
assert_eq!(tags_columns.len(), 2);
let tiering_columns = [
"Name",
"Status",
"Scope",
"Days to Archive",
"Days to Deep Archive",
];
assert_eq!(tiering_columns.len(), 5);
let events_columns = [
"Name",
"Event types",
"Filters",
"Destination type",
"Destination",
];
assert_eq!(events_columns.len(), 5);
}
#[test]
fn test_s3_properties_field_format() {
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
let label = Line::from(vec![Span::styled(
"AWS Region",
Style::default().add_modifier(Modifier::BOLD),
)]);
let value = Line::from("us-east-1");
assert!(label.spans[0].style.add_modifier.contains(Modifier::BOLD));
assert!(!value.spans[0].style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_s3_properties_has_scrollbar() {
let total_height = 7 + 5 + 6 + 5 + 4 + 4 + 5 + 4 + 4 + 4 + 4 + 4;
assert_eq!(total_height, 56);
let area_height = 40;
assert!(total_height > area_height);
}
#[test]
fn test_s3_bucket_region_fetched_on_open() {
let empty_region = "";
assert!(empty_region.is_empty());
let fetched_region = "us-west-2";
assert!(!fetched_region.is_empty());
}
#[test]
fn test_s3_filter_space_used_when_hidden() {
let objects_chunks = 4;
let other_chunks = 3;
assert_eq!(objects_chunks, 4);
assert_eq!(other_chunks, 3);
assert!(other_chunks < objects_chunks);
}
#[test]
fn test_s3_properties_scrollable() {
let mut app = test_app();
assert_eq!(app.s3_state.properties_scroll, 0);
app.s3_state.properties_scroll += 1;
assert_eq!(app.s3_state.properties_scroll, 1);
app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
assert_eq!(app.s3_state.properties_scroll, 0);
}
#[test]
fn test_s3_properties_scrollbar_conditional() {
let content_height = 40;
let small_viewport = 20;
let large_viewport = 50;
assert!(content_height > small_viewport);
assert!(content_height < large_viewport);
}
#[test]
fn test_s3_tabs_visible_with_styling() {
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
let active_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
let active_tab = Span::styled("Objects", active_style);
assert_eq!(active_tab.style.fg, Some(Color::Yellow));
assert!(active_tab.style.add_modifier.contains(Modifier::BOLD));
assert!(active_tab.style.add_modifier.contains(Modifier::UNDERLINED));
let inactive_style = Style::default().fg(Color::Gray);
let inactive_tab = Span::styled("Properties", inactive_style);
assert_eq!(inactive_tab.style.fg, Some(Color::Gray));
}
#[test]
fn test_s3_properties_field_labels_bold() {
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
let label = Span::styled(
"AWS Region: ",
Style::default().add_modifier(Modifier::BOLD),
);
let value = Span::raw("us-east-1");
let line = Line::from(vec![label.clone(), value.clone()]);
assert!(label.style.add_modifier.contains(Modifier::BOLD));
assert!(!value.style.add_modifier.contains(Modifier::BOLD));
assert_eq!(line.spans.len(), 2);
}
#[test]
fn test_session_picker_dialog_opaque() {
}
#[test]
fn test_status_bar_hotkey_format() {
let separator = " ⋮ ";
assert_eq!(separator, " ⋮ ");
let ctrl_key = "^r";
assert!(ctrl_key.starts_with("^"));
assert!(!ctrl_key.contains("ctrl+"));
assert!(!ctrl_key.contains("ctrl-"));
let shift_key = "^R";
assert!(shift_key.contains("^R"));
assert!(!shift_key.contains("shift+"));
assert!(!shift_key.contains("shift-"));
let old_separator = " | ";
assert_ne!(separator, old_separator);
}
#[test]
fn test_space_key_uses_unicode_symbol() {
let space_symbol = "␣";
assert_eq!(space_symbol, "␣");
assert_eq!(space_symbol.len(), 3);
assert_ne!(space_symbol, "space");
assert_ne!(space_symbol, "SPC");
}
#[test]
fn test_region_hotkey_uses_space_menu() {
let region_hotkey = "␣→r";
assert_eq!(region_hotkey, "␣→r");
assert_ne!(region_hotkey, "^R");
assert_ne!(region_hotkey, "ctrl+shift+r");
}
#[test]
fn test_no_incorrect_hotkey_patterns_in_ui() {
let source = include_str!("mod.rs");
let ui_code = if let Some(pos) = source.find("#[cfg(test)]") {
&source[..pos]
} else {
source
};
let space_text_pattern = r#"Span::styled("space""#;
assert!(
!ui_code.contains(space_text_pattern),
"Found 'space' text in hotkey - should use ␣ symbol instead"
);
let lines_with_ctrl_shift_r: Vec<_> = ui_code
.lines()
.enumerate()
.filter(|(_, line)| {
line.contains(r#"Span::styled("^R""#) && line.contains("Color::Red")
})
.collect();
assert!(
lines_with_ctrl_shift_r.is_empty(),
"Found ^R in hotkeys (should use ␣→r for region): {:?}",
lines_with_ctrl_shift_r
);
}
#[test]
fn test_region_only_in_space_menu_not_status_bar() {
let source = include_str!("mod.rs");
let space_menu_start = source
.find("fn render_space_menu")
.expect("render_space_menu function not found");
let space_menu_end = space_menu_start
+ source[space_menu_start..]
.find("fn render_service_picker")
.expect("render_service_picker not found");
let space_menu_code = &source[space_menu_start..space_menu_end];
assert!(
space_menu_code.contains(r#"Span::raw(" regions")"#),
"Region must be in Space menu"
);
let status_bar_start = source
.find("fn render_bottom_bar")
.expect("render_bottom_bar function not found");
let status_bar_end = status_bar_start
+ source[status_bar_start..]
.find("\nfn render_")
.expect("Next function not found");
let status_bar_code = &source[status_bar_start..status_bar_end];
assert!(
!status_bar_code.contains(" region ⋮ "),
"Region hotkey must NOT be in status bar - it's only in Space menu!"
);
assert!(
!status_bar_code.contains("␣→r"),
"Region hotkey (␣→r) must NOT be in status bar - it's only in Space menu!"
);
assert!(
!status_bar_code.contains("^R"),
"Region hotkey (^R) must NOT be in status bar - it's only in Space menu!"
);
}
#[test]
fn test_s3_bucket_preview_permanent_redirect_handled() {
let error_msg = "PermanentRedirect";
assert!(error_msg.contains("PermanentRedirect"));
let mut preview_map: std::collections::HashMap<String, Vec<crate::app::S3Object>> =
std::collections::HashMap::new();
preview_map.insert("bucket".to_string(), vec![]);
assert!(preview_map.contains_key("bucket"));
}
#[test]
fn test_s3_objects_hint_is_open() {
let hint = "open";
assert_eq!(hint, "open");
assert_ne!(hint, "drill down");
assert_ne!(hint, "open folder");
}
#[test]
fn test_s3_service_tabs_use_cyan() {
let active_color = Color::Cyan;
assert_eq!(active_color, Color::Cyan);
assert_ne!(active_color, Color::Yellow);
}
#[test]
fn test_s3_column_names_use_orange() {
let column_color = Color::LightRed;
assert_eq!(column_color, Color::LightRed);
}
#[test]
fn test_s3_bucket_errors_shown_in_expanded_rows() {
let mut errors: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
errors.insert("bucket".to_string(), "Error message".to_string());
assert!(errors.contains_key("bucket"));
assert_eq!(errors.get("bucket").unwrap(), "Error message");
}
#[test]
fn test_cloudwatch_alarms_page_input() {
let mut app = test_app();
app.current_service = Service::CloudWatchAlarms;
app.page_input = "2".to_string();
assert_eq!(app.page_input, "2");
}
#[test]
fn test_tabs_row_shows_profile_info() {
let profile = "default";
let account = "123456789012";
let region = "us-west-2";
let identity = "role:/MyRole";
let info = format!(
"Profile: {} ⋮ Account: {} ⋮ Region: {} ⋮ Identity: {}",
profile, account, region, identity
);
assert!(info.contains("Profile:"));
assert!(info.contains("Account:"));
assert!(info.contains("Region:"));
assert!(info.contains("Identity:"));
assert!(info.contains("⋮"));
}
#[test]
fn test_tabs_row_profile_labels_are_bold() {
let label_style = Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD);
assert!(label_style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_profile_info_not_duplicated() {
let breadcrumbs = "CloudWatch > Alarms";
assert!(!breadcrumbs.contains("Profile:"));
assert!(!breadcrumbs.contains("Account:"));
}
#[test]
fn test_s3_column_headers_are_cyan() {
let header_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
assert_eq!(header_style.fg, Some(Color::Cyan));
assert!(header_style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_s3_nested_objects_can_be_expanded() {
let mut app = test_app();
app.current_service = Service::S3Buckets;
app.s3_state.current_bucket = Some("bucket".to_string());
app.s3_state.objects.push(crate::app::S3Object {
key: "folder1/".to_string(),
size: 0,
last_modified: String::new(),
is_prefix: true,
storage_class: String::new(),
});
app.s3_state
.expanded_prefixes
.insert("folder1/".to_string());
let nested = vec![crate::app::S3Object {
key: "folder1/subfolder/".to_string(),
size: 0,
last_modified: String::new(),
is_prefix: true,
storage_class: String::new(),
}];
app.s3_state
.prefix_preview
.insert("folder1/".to_string(), nested);
app.s3_state.selected_object = 1;
assert!(app.s3_state.current_bucket.is_some());
}
#[test]
fn test_s3_nested_folder_shows_expand_indicator() {
use crate::app::{S3Object, Service};
let mut app = test_app();
app.current_service = Service::S3Buckets;
app.s3_state.current_bucket = Some("test-bucket".to_string());
app.s3_state.objects = vec![S3Object {
key: "parent/".to_string(),
size: 0,
last_modified: "2024-01-01T00:00:00Z".to_string(),
is_prefix: true,
storage_class: String::new(),
}];
app.s3_state.expanded_prefixes.insert("parent/".to_string());
app.s3_state.prefix_preview.insert(
"parent/".to_string(),
vec![S3Object {
key: "parent/child/".to_string(),
size: 0,
last_modified: "2024-01-01T00:00:00Z".to_string(),
is_prefix: true,
storage_class: String::new(),
}],
);
let child = &app.s3_state.prefix_preview.get("parent/").unwrap()[0];
let is_expanded = app.s3_state.expanded_prefixes.contains(&child.key);
let indicator = if is_expanded { "▼ " } else { "▶ " };
assert_eq!(indicator, "▶ ");
app.s3_state
.expanded_prefixes
.insert("parent/child/".to_string());
let is_expanded = app.s3_state.expanded_prefixes.contains(&child.key);
let indicator = if is_expanded { "▼ " } else { "▶ " };
assert_eq!(indicator, "▼ ");
}
#[test]
fn test_tabs_row_always_visible() {
let app = test_app();
assert!(!app.service_selected); }
#[test]
fn test_no_duplicate_breadcrumbs_at_root() {
let mut app = test_app();
app.current_service = Service::CloudWatchAlarms;
app.service_selected = true;
app.tabs.push(crate::app::Tab {
service: Service::CloudWatchAlarms,
title: "CloudWatch > Alarms".to_string(),
breadcrumb: "CloudWatch > Alarms".to_string(),
});
assert_eq!(app.breadcrumbs(), "CloudWatch > Alarms");
}
#[test]
fn test_preferences_headers_use_cyan_underline() {
let header_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
assert_eq!(header_style.fg, Some(Color::Cyan));
assert!(header_style.add_modifier.contains(Modifier::BOLD));
assert!(header_style.add_modifier.contains(Modifier::UNDERLINED));
let header_text = "Columns";
assert!(!header_text.contains("═"));
}
#[test]
fn test_alarm_pagination_shows_actual_pages() {
let page_size = 10;
let total_items = 25;
let total_pages = (total_items + page_size - 1) / page_size;
let current_page = 1;
let pagination = format!("Page {} of {}", current_page, total_pages);
assert_eq!(pagination, "Page 1 of 3");
assert!(!pagination.contains("[1]"));
assert!(!pagination.contains("[2]"));
}
#[test]
fn test_mode_indicator_uses_insert_not_input() {
let mode_text = " INSERT ";
assert_eq!(mode_text, " INSERT ");
assert_ne!(mode_text, " INPUT ");
}
#[test]
fn test_service_picker_shows_insert_mode_when_typing() {
let mut app = test_app();
app.mode = Mode::ServicePicker;
app.service_picker.filter = "cloud".to_string();
assert!(!app.service_picker.filter.is_empty());
}
#[test]
fn test_log_events_no_horizontal_scrollbar() {
let app = test_app();
assert_eq!(app.cw_log_event_visible_column_ids.len(), 2);
assert_eq!(app.log_groups_state.event_horizontal_scroll, 0);
}
#[test]
fn test_log_events_expansion_stays_visible_when_scrolling() {
let mut app = test_app();
app.log_groups_state.expanded_event = Some(0);
app.log_groups_state.event_scroll_offset = 0;
app.log_groups_state.event_scroll_offset = 1;
assert_eq!(app.log_groups_state.expanded_event, Some(0));
}
#[test]
fn test_log_events_right_arrow_expands() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::Events;
app.log_groups_state.log_events = vec![rusticity_core::LogEvent {
timestamp: chrono::Utc::now(),
message: "Test log message".to_string(),
}];
app.log_groups_state.event_scroll_offset = 0;
assert_eq!(app.log_groups_state.expanded_event, None);
app.handle_action(Action::NextPane);
assert_eq!(app.log_groups_state.expanded_event, Some(0));
}
#[test]
fn test_log_events_left_arrow_collapses() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::Events;
app.log_groups_state.log_events = vec![rusticity_core::LogEvent {
timestamp: chrono::Utc::now(),
message: "Test log message".to_string(),
}];
app.log_groups_state.event_scroll_offset = 0;
app.log_groups_state.expanded_event = Some(0);
app.handle_action(Action::PrevPane);
assert_eq!(app.log_groups_state.expanded_event, None);
}
#[test]
fn test_log_events_expanded_content_replaces_tabs() {
let message_with_tabs = "[INFO]\t2025-10-22T13:41:37.601Z\tb2227e1c";
let cleaned = message_with_tabs.replace('\t', " ");
assert!(!cleaned.contains('\t'));
assert!(cleaned.contains(" "));
assert_eq!(cleaned, "[INFO] 2025-10-22T13:41:37.601Z b2227e1c");
}
#[test]
fn test_log_events_navigation_skips_expanded_overlay() {
let mut app = test_app();
app.log_groups_state.expanded_event = Some(0);
app.log_groups_state.event_scroll_offset = 0;
app.log_groups_state.event_scroll_offset = 1;
assert_eq!(app.log_groups_state.event_scroll_offset, 1);
assert_eq!(app.log_groups_state.expanded_event, Some(0));
}
#[test]
fn test_log_events_empty_rows_reserve_space_for_overlay() {
let message = "Long message that will wrap across multiple lines when expanded";
let max_width = 50;
let full_line = format!("Message: {}", message);
let line_count = full_line.len().div_ceil(max_width);
assert!(line_count >= 2);
}
#[test]
fn test_preferences_title_no_hints() {
let s3_title = " Preferences ";
let events_title = " Preferences ";
let alarms_title = " Preferences ";
assert_eq!(s3_title.trim(), "Preferences");
assert_eq!(events_title.trim(), "Preferences");
assert_eq!(alarms_title.trim(), "Preferences");
assert!(!s3_title.contains("Space"));
assert!(!events_title.contains("Space"));
assert!(!alarms_title.contains("Tab"));
}
#[test]
fn test_page_navigation_works_for_events() {
let mut app = test_app();
app.view_mode = ViewMode::Events;
app.log_groups_state.event_scroll_offset = 0;
let page = 2;
let page_size = 20;
let target_index = (page - 1) * page_size;
assert_eq!(target_index, 20);
app.page_input.clear();
assert!(app.page_input.is_empty());
}
#[test]
fn test_status_bar_shows_tab_hint_for_alarms_preferences() {
let app = test_app();
assert_eq!(app.current_service, Service::CloudWatchLogGroups);
}
#[test]
fn test_column_selector_shows_correct_columns_per_service() {
use crate::app::Service;
let mut app = test_app();
app.current_service = Service::S3Buckets;
let bucket_col_names: Vec<String> = app
.s3_bucket_column_ids
.iter()
.filter_map(|id| BucketColumn::from_id(id).map(|c| c.name()))
.collect();
assert_eq!(bucket_col_names, vec!["Name", "Region", "Creation date"]);
app.current_service = Service::CloudWatchLogGroups;
let log_col_names: Vec<String> = app
.cw_log_group_column_ids
.iter()
.filter_map(|id| LogGroupColumn::from_id(id).map(|c| c.name().to_string()))
.collect();
assert_eq!(
log_col_names,
vec![
"Log group",
"Log class",
"Retention",
"Stored bytes",
"Creation time",
"ARN"
]
);
app.current_service = Service::CloudWatchAlarms;
assert!(!app.cw_alarm_column_ids.is_empty());
if let Some(col) = AlarmColumn::from_id(app.cw_alarm_column_ids[0]) {
assert!(col.name().contains("Name") || col.name().contains("Alarm"));
}
}
#[test]
fn test_log_groups_preferences_shows_all_six_columns() {
use crate::app::Service;
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
assert_eq!(app.cw_log_group_column_ids.len(), 6);
let col_names: Vec<String> = app
.cw_log_group_column_ids
.iter()
.filter_map(|id| LogGroupColumn::from_id(id).map(|c| c.name().to_string()))
.collect();
assert!(col_names.iter().any(|n| n == "Log group"));
assert!(col_names.iter().any(|n| n == "Log class"));
assert!(col_names.iter().any(|n| n == "Retention"));
assert!(col_names.iter().any(|n| n == "Stored bytes"));
assert!(col_names.iter().any(|n| n == "Creation time"));
assert!(col_names.iter().any(|n| n == "ARN"));
}
#[test]
fn test_stream_preferences_shows_all_columns() {
use crate::app::ViewMode;
let mut app = test_app();
app.view_mode = ViewMode::Detail;
assert!(!app.cw_log_stream_column_ids.is_empty());
assert_eq!(app.cw_log_stream_column_ids.len(), 7);
}
#[test]
fn test_event_preferences_shows_all_columns() {
use crate::app::ViewMode;
let mut app = test_app();
app.view_mode = ViewMode::Events;
assert!(!app.cw_log_event_column_ids.is_empty());
assert_eq!(app.cw_log_event_column_ids.len(), 5);
}
#[test]
fn test_alarm_preferences_shows_all_columns() {
use crate::app::Service;
let mut app = test_app();
app.current_service = Service::CloudWatchAlarms;
assert!(!app.cw_alarm_column_ids.is_empty());
assert_eq!(app.cw_alarm_column_ids.len(), 16);
}
#[test]
fn test_column_selector_has_scrollbar() {
let item_count = 6; assert!(item_count > 0);
}
#[test]
fn test_preferences_scrollbar_only_when_needed() {
let item_count = 6;
let height = (item_count as u16 + 2).max(8); let max_height_fits = 20; let max_height_doesnt_fit = 5;
let needs_scrollbar_fits = height > max_height_fits;
assert!(!needs_scrollbar_fits);
let needs_scrollbar_doesnt_fit = height > max_height_doesnt_fit;
assert!(needs_scrollbar_doesnt_fit);
}
#[test]
fn test_preferences_height_no_extra_padding() {
let item_count = 6;
let height = (item_count as u16 + 2).max(8);
assert_eq!(height, 8);
assert_ne!(height, 10); }
#[test]
fn test_preferences_uses_absolute_sizing() {
let width = 50u16; let height = 10u16;
assert!(width <= 100); assert!(height <= 50); }
#[test]
fn test_profile_picker_shows_sort_indicator() {
let sort_column = "Profile";
let sort_direction = "ASC";
assert_eq!(sort_column, "Profile");
assert_eq!(sort_direction, "ASC");
let arrow = if sort_direction == "ASC" {
" ↑"
} else {
" ↓"
};
assert_eq!(arrow, " ↑");
}
#[test]
fn test_session_picker_shows_sort_indicator() {
let sort_column = "Timestamp";
let sort_direction = "DESC";
assert_eq!(sort_column, "Timestamp");
assert_eq!(sort_direction, "DESC");
let arrow = if sort_direction == "ASC" {
" ↑"
} else {
" ↓"
};
assert_eq!(arrow, " ↓");
}
#[test]
fn test_profile_picker_sorted_ascending() {
let mut app = test_app_no_region();
app.available_profiles = vec![
crate::app::AwsProfile {
name: "zebra".to_string(),
region: None,
account: None,
role_arn: None,
source_profile: None,
},
crate::app::AwsProfile {
name: "alpha".to_string(),
region: None,
account: None,
role_arn: None,
source_profile: None,
},
];
let filtered = app.get_filtered_profiles();
assert_eq!(filtered[0].name, "alpha");
assert_eq!(filtered[1].name, "zebra");
}
#[test]
fn test_session_picker_sorted_descending() {
let mut app = test_app_no_region();
app.sessions = vec![
crate::session::Session {
id: "2".to_string(),
timestamp: "2024-01-02 10:00:00 UTC".to_string(),
profile: "new".to_string(),
region: "us-east-1".to_string(),
account_id: "123".to_string(),
role_arn: String::new(),
tabs: vec![],
},
crate::session::Session {
id: "1".to_string(),
timestamp: "2024-01-01 10:00:00 UTC".to_string(),
profile: "old".to_string(),
region: "us-east-1".to_string(),
account_id: "123".to_string(),
role_arn: String::new(),
tabs: vec![],
},
];
let filtered = app.get_filtered_sessions();
assert_eq!(filtered[0].profile, "new");
assert_eq!(filtered[1].profile, "old");
}
#[test]
fn test_ecr_encryption_type_aes256_renders_as_aes_dash_256() {
let repo = EcrRepository {
name: "test-repo".to_string(),
uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
created_at: "2024-01-01".to_string(),
tag_immutability: "MUTABLE".to_string(),
encryption_type: "AES256".to_string(),
};
let formatted = match repo.encryption_type.as_ref() {
"AES256" => "AES-256".to_string(),
"KMS" => "KMS".to_string(),
other => other.to_string(),
};
assert_eq!(formatted, "AES-256");
}
#[test]
fn test_ecr_encryption_type_kms_unchanged() {
let repo = EcrRepository {
name: "test-repo".to_string(),
uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
created_at: "2024-01-01".to_string(),
tag_immutability: "MUTABLE".to_string(),
encryption_type: "KMS".to_string(),
};
let formatted = match repo.encryption_type.as_ref() {
"AES256" => "AES-256".to_string(),
"KMS" => "KMS".to_string(),
other => other.to_string(),
};
assert_eq!(formatted, "KMS");
}
#[test]
fn test_ecr_repo_filter_active_removes_table_focus() {
let mut app = test_app_no_region();
app.current_service = Service::EcrRepositories;
app.mode = Mode::FilterInput;
app.ecr_state.repositories.items = vec![EcrRepository {
name: "test-repo".to_string(),
uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
created_at: "2024-01-01".to_string(),
tag_immutability: "MUTABLE".to_string(),
encryption_type: "AES256".to_string(),
}];
assert_eq!(app.mode, Mode::FilterInput);
}
#[test]
fn test_ecr_image_filter_active_removes_table_focus() {
let mut app = test_app_no_region();
app.current_service = Service::EcrRepositories;
app.ecr_state.current_repository = Some("test-repo".to_string());
app.mode = Mode::FilterInput;
app.ecr_state.images.items = vec![EcrImage {
tag: "v1.0.0".to_string(),
artifact_type: "application/vnd.docker.container.image.v1+json".to_string(),
pushed_at: "2024-01-01".to_string(),
size_bytes: 104857600,
uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo:v1.0.0".to_string(),
digest: "sha256:abc123".to_string(),
last_pull_time: "2024-01-02".to_string(),
}];
assert_eq!(app.mode, Mode::FilterInput);
}
#[test]
fn test_ecr_filter_escape_returns_to_normal_mode() {
let mut app = test_app_no_region();
app.current_service = Service::EcrRepositories;
app.mode = Mode::FilterInput;
app.ecr_state.repositories.filter = "test".to_string();
app.handle_action(crate::keymap::Action::CloseMenu);
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn test_ecr_repos_no_scrollbar_when_all_fit() {
let mut app = test_app_no_region();
app.current_service = Service::EcrRepositories;
app.ecr_state.repositories.items = (0..50)
.map(|i| EcrRepository {
name: format!("repo{}", i),
uri: format!("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo{}", i),
created_at: "2024-01-01".to_string(),
tag_immutability: "MUTABLE".to_string(),
encryption_type: "AES256".to_string(),
})
.collect();
let row_count = 50;
let typical_area_height: u16 = 60;
let available_height = typical_area_height.saturating_sub(3);
assert!(
row_count <= available_height as usize,
"50 repos should fit without scrollbar"
);
}
#[test]
fn test_lambda_default_columns() {
let app = test_app_no_region();
assert_eq!(app.lambda_state.function_visible_column_ids.len(), 6);
assert_eq!(
app.lambda_state.function_visible_column_ids[0],
"column.lambda.function.name"
);
assert_eq!(
app.lambda_state.function_visible_column_ids[1],
"column.lambda.function.runtime"
);
assert_eq!(
app.lambda_state.function_visible_column_ids[2],
"column.lambda.function.code_size"
);
assert_eq!(
app.lambda_state.function_visible_column_ids[3],
"column.lambda.function.memory_mb"
);
assert_eq!(
app.lambda_state.function_visible_column_ids[4],
"column.lambda.function.timeout_seconds"
);
assert_eq!(
app.lambda_state.function_visible_column_ids[5],
"column.lambda.function.last_modified"
);
}
#[test]
fn test_lambda_all_columns_available() {
let all_columns = lambda::FunctionColumn::ids();
assert_eq!(all_columns.len(), 9);
assert!(all_columns.contains(&"column.lambda.function.name"));
assert!(all_columns.contains(&"column.lambda.function.description"));
assert!(all_columns.contains(&"column.lambda.function.package_type"));
assert!(all_columns.contains(&"column.lambda.function.runtime"));
assert!(all_columns.contains(&"column.lambda.function.architecture"));
assert!(all_columns.contains(&"column.lambda.function.code_size"));
assert!(all_columns.contains(&"column.lambda.function.memory_mb"));
assert!(all_columns.contains(&"column.lambda.function.timeout_seconds"));
assert!(all_columns.contains(&"column.lambda.function.last_modified"));
}
#[test]
fn test_lambda_filter_active_removes_table_focus() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.mode = Mode::FilterInput;
app.lambda_state.table.items = vec![lambda::Function {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "test-function".to_string(),
description: "Test function".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
layers: vec![],
}];
assert_eq!(app.mode, Mode::FilterInput);
}
#[test]
fn test_lambda_default_page_size() {
let app = test_app_no_region();
assert_eq!(app.lambda_state.table.page_size, PageSize::Fifty);
assert_eq!(app.lambda_state.table.page_size.value(), 50);
}
#[test]
fn test_lambda_pagination() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.lambda_state.table.page_size = PageSize::Ten;
app.lambda_state.table.items = (0..25)
.map(|i| crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: format!("function-{}", i),
description: format!("Function {}", i),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
layers: vec![],
})
.collect();
let page_size = app.lambda_state.table.page_size.value();
let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
assert_eq!(page_size, 10);
assert_eq!(total_pages, 3);
}
#[test]
fn test_lambda_filter_by_name() {
let mut app = test_app_no_region();
app.lambda_state.table.items = vec![
crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "api-handler".to_string(),
description: "API handler".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
layers: vec![],
},
crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "data-processor".to_string(),
description: "Data processor".to_string(),
package_type: "Zip".to_string(),
runtime: "nodejs20.x".to_string(),
architecture: "arm64".to_string(),
code_size: 2048,
code_sha256: "test-sha256".to_string(),
memory_mb: 256,
timeout_seconds: 30,
last_modified: "2024-01-02T00:00:00.000+0000".to_string(),
layers: vec![],
},
];
app.lambda_state.table.filter = "api".to_string();
let filtered: Vec<_> = app
.lambda_state
.table
.items
.iter()
.filter(|f| {
app.lambda_state.table.filter.is_empty()
|| f.name
.to_lowercase()
.contains(&app.lambda_state.table.filter.to_lowercase())
|| f.description
.to_lowercase()
.contains(&app.lambda_state.table.filter.to_lowercase())
|| f.runtime
.to_lowercase()
.contains(&app.lambda_state.table.filter.to_lowercase())
})
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "api-handler");
}
#[test]
fn test_lambda_filter_by_runtime() {
let mut app = test_app_no_region();
app.lambda_state.table.items = vec![
crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "python-func".to_string(),
description: "Python function".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
layers: vec![],
},
crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "node-func".to_string(),
description: "Node function".to_string(),
package_type: "Zip".to_string(),
runtime: "nodejs20.x".to_string(),
architecture: "arm64".to_string(),
code_size: 2048,
code_sha256: "test-sha256".to_string(),
memory_mb: 256,
timeout_seconds: 30,
last_modified: "2024-01-02T00:00:00.000+0000".to_string(),
layers: vec![],
},
];
app.lambda_state.table.filter = "python".to_string();
let filtered: Vec<_> = app
.lambda_state
.table
.items
.iter()
.filter(|f| {
app.lambda_state.table.filter.is_empty()
|| f.name
.to_lowercase()
.contains(&app.lambda_state.table.filter.to_lowercase())
|| f.description
.to_lowercase()
.contains(&app.lambda_state.table.filter.to_lowercase())
|| f.runtime
.to_lowercase()
.contains(&app.lambda_state.table.filter.to_lowercase())
})
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].runtime, "python3.12");
}
#[test]
fn test_lambda_page_size_changes_in_preferences() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.lambda_state.table.page_size = PageSize::Fifty;
app.mode = Mode::ColumnSelector;
app.column_selector_index = 12; app.handle_action(crate::keymap::Action::ToggleColumn);
assert_eq!(app.lambda_state.table.page_size, PageSize::Ten);
}
#[test]
fn test_lambda_preferences_shows_page_sizes() {
let app = test_app_no_region();
let mut app = app;
app.current_service = Service::LambdaFunctions;
let page_sizes = vec![
PageSize::Ten,
PageSize::TwentyFive,
PageSize::Fifty,
PageSize::OneHundred,
];
for size in page_sizes {
app.lambda_state.table.page_size = size;
assert_eq!(app.lambda_state.table.page_size, size);
}
}
#[test]
fn test_lambda_pagination_respects_page_size() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.lambda_state.table.items = (0..100)
.map(|i| crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: format!("function-{}", i),
description: format!("Function {}", i),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
layers: vec![],
})
.collect();
app.lambda_state.table.page_size = PageSize::Ten;
let page_size = app.lambda_state.table.page_size.value();
let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
assert_eq!(page_size, 10);
assert_eq!(total_pages, 10);
app.lambda_state.table.page_size = PageSize::TwentyFive;
let page_size = app.lambda_state.table.page_size.value();
let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
assert_eq!(page_size, 25);
assert_eq!(total_pages, 4);
app.lambda_state.table.page_size = PageSize::Fifty;
let page_size = app.lambda_state.table.page_size.value();
let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
assert_eq!(page_size, 50);
assert_eq!(total_pages, 2);
}
#[test]
fn test_lambda_next_preferences_cycles_sections() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.mode = Mode::ColumnSelector;
app.column_selector_index = 0;
app.handle_action(crate::keymap::Action::NextPreferences);
assert_eq!(app.column_selector_index, 11);
app.handle_action(crate::keymap::Action::NextPreferences);
assert_eq!(app.column_selector_index, 0);
}
#[test]
fn test_lambda_drill_down_on_enter() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.service_selected = true;
app.mode = Mode::Normal;
app.lambda_state.table.items = vec![crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "test-function".to_string(),
description: "Test function".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
layers: vec![],
}];
app.lambda_state.table.selected = 0;
app.handle_action(crate::keymap::Action::Select);
assert_eq!(
app.lambda_state.current_function,
Some("test-function".to_string())
);
assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
}
#[test]
fn test_lambda_go_back_from_detail() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.lambda_state.current_function = Some("test-function".to_string());
app.handle_action(crate::keymap::Action::GoBack);
assert_eq!(app.lambda_state.current_function, None);
}
#[test]
fn test_lambda_detail_tab_cycling() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.lambda_state.current_function = Some("test-function".to_string());
app.lambda_state.detail_tab = LambdaDetailTab::Code;
app.handle_action(crate::keymap::Action::NextDetailTab);
assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Monitor);
app.handle_action(crate::keymap::Action::NextDetailTab);
assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Configuration);
app.handle_action(crate::keymap::Action::NextDetailTab);
assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Aliases);
app.handle_action(crate::keymap::Action::NextDetailTab);
assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Versions);
app.handle_action(crate::keymap::Action::NextDetailTab);
assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
}
#[test]
fn test_lambda_breadcrumbs_with_function_name() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.service_selected = true;
let breadcrumb = app.breadcrumbs();
assert_eq!(breadcrumb, "Lambda > Functions");
app.lambda_state.current_function = Some("my-function".to_string());
let breadcrumb = app.breadcrumbs();
assert_eq!(breadcrumb, "Lambda > my-function");
}
#[test]
fn test_lambda_console_url() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.config.region = "us-east-1".to_string();
let url = app.get_console_url();
assert_eq!(
url,
"https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions"
);
app.lambda_state.current_function = Some("my-function".to_string());
let url = app.get_console_url();
assert_eq!(
url,
"https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions/my-function"
);
}
#[test]
fn test_lambda_last_modified_format() {
let func = crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "test-function".to_string(),
description: "Test function".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01 12:30:45 (UTC)".to_string(),
layers: vec![],
};
assert!(func.last_modified.contains("(UTC)"));
assert!(func.last_modified.contains("2024-01-01"));
}
#[test]
fn test_lambda_expand_on_right_arrow() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.service_selected = true;
app.mode = Mode::Normal;
app.lambda_state.table.items = vec![crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "test-function".to_string(),
description: "Test function".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
layers: vec![],
}];
app.lambda_state.table.selected = 0;
app.handle_action(crate::keymap::Action::NextPane);
assert_eq!(app.lambda_state.table.expanded_item, Some(0));
}
#[test]
fn test_lambda_collapse_on_left_arrow() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.service_selected = true;
app.mode = Mode::Normal;
app.lambda_state.current_function = None; app.lambda_state.table.expanded_item = Some(0);
app.handle_action(crate::keymap::Action::PrevPane);
assert_eq!(app.lambda_state.table.expanded_item, None);
}
#[test]
fn test_lambda_filter_activation() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.service_selected = true;
app.mode = Mode::Normal;
app.handle_action(crate::keymap::Action::StartFilter);
assert_eq!(app.mode, Mode::FilterInput);
}
#[test]
fn test_lambda_filter_backspace() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.mode = Mode::FilterInput;
app.lambda_state.table.filter = "test".to_string();
app.handle_action(crate::keymap::Action::FilterBackspace);
assert_eq!(app.lambda_state.table.filter, "tes");
}
#[test]
fn test_lambda_sorted_by_last_modified_desc() {
let func1 = crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "func1".to_string(),
description: String::new(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
layers: vec![],
};
let func2 = crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "func2".to_string(),
description: String::new(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-12-31 00:00:00 (UTC)".to_string(),
layers: vec![],
};
let mut functions = [func1.clone(), func2.clone()].to_vec();
functions.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
assert_eq!(functions[0].name, "func2");
assert_eq!(functions[1].name, "func1");
}
#[test]
fn test_lambda_code_properties_has_sha256() {
let func = crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "test-function".to_string(),
description: "Test".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 2600,
code_sha256: "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE=".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
layers: vec![],
};
assert!(!func.code_sha256.is_empty());
assert_eq!(
func.code_sha256,
"HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE="
);
}
#[test]
fn test_lambda_name_column_has_expand_symbol() {
let func = crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "test-function".to_string(),
description: "Test".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
layers: vec![],
};
let symbol_collapsed = crate::ui::table::CURSOR_COLLAPSED;
let rendered_collapsed = format!("{} {}", symbol_collapsed, func.name);
assert!(rendered_collapsed.contains(symbol_collapsed));
assert!(rendered_collapsed.contains("test-function"));
let symbol_expanded = crate::ui::table::CURSOR_EXPANDED;
let rendered_expanded = format!("{} {}", symbol_expanded, func.name);
assert!(rendered_expanded.contains(symbol_expanded));
assert!(rendered_expanded.contains("test-function"));
assert_ne!(symbol_collapsed, symbol_expanded);
}
#[test]
fn test_lambda_last_modified_column_width() {
let timestamp = "2025-10-31 08:37:46 (UTC)";
assert_eq!(timestamp.len(), 25);
let width = 27u16;
assert!(width >= timestamp.len() as u16);
}
#[test]
fn test_lambda_code_properties_has_info_and_kms_sections() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.lambda_state.current_function = Some("test-function".to_string());
app.lambda_state.detail_tab = LambdaDetailTab::Code;
app.lambda_state.table.items = vec![crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: "test-function".to_string(),
description: "Test".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 2600,
code_sha256: "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE=".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
layers: vec![],
}];
assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
assert!(app.lambda_state.current_function.is_some());
assert_eq!(app.lambda_state.table.items.len(), 1);
let func = &app.lambda_state.table.items[0];
assert!(!func.code_sha256.is_empty());
assert!(!func.last_modified.is_empty());
assert!(func.code_size > 0);
}
#[test]
fn test_lambda_pagination_navigation() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.service_selected = true;
app.mode = Mode::Normal;
app.lambda_state.table.page_size = PageSize::Ten;
app.lambda_state.table.items = (0..25)
.map(|i| crate::app::LambdaFunction {
arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
application: None,
name: format!("function-{}", i),
description: "Test".to_string(),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024,
code_sha256: "test-sha256".to_string(),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
layers: vec![],
})
.collect();
app.lambda_state.table.selected = 0;
let page_size = app.lambda_state.table.page_size.value();
let current_page = app.lambda_state.table.selected / page_size;
assert_eq!(current_page, 0);
assert_eq!(app.lambda_state.table.selected % page_size, 0);
app.lambda_state.table.selected = 10;
let current_page = app.lambda_state.table.selected / page_size;
assert_eq!(current_page, 1);
assert_eq!(app.lambda_state.table.selected % page_size, 0);
app.lambda_state.table.selected = 15;
let current_page = app.lambda_state.table.selected / page_size;
assert_eq!(current_page, 1);
assert_eq!(app.lambda_state.table.selected % page_size, 5);
}
#[test]
fn test_lambda_pagination_with_100_functions() {
let mut app = test_app_no_region();
app.current_service = Service::LambdaFunctions;
app.service_selected = true;
app.mode = Mode::Normal;
app.lambda_state.table.page_size = PageSize::Fifty;
app.lambda_state.table.items = (0..100)
.map(|i| crate::app::LambdaFunction {
arn: format!("arn:aws:lambda:us-east-1:123456789012:function:func-{}", i),
application: None,
name: format!("function-{:03}", i),
description: format!("Function {}", i),
package_type: "Zip".to_string(),
runtime: "python3.12".to_string(),
architecture: "x86_64".to_string(),
code_size: 1024 + i,
code_sha256: format!("sha256-{}", i),
memory_mb: 128,
timeout_seconds: 3,
last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
layers: vec![],
})
.collect();
let page_size = app.lambda_state.table.page_size.value();
assert_eq!(page_size, 50);
app.lambda_state.table.selected = 0;
let current_page = app.lambda_state.table.selected / page_size;
assert_eq!(current_page, 0);
app.lambda_state.table.selected = 49;
let current_page = app.lambda_state.table.selected / page_size;
assert_eq!(current_page, 0);
app.lambda_state.table.selected = 50;
let current_page = app.lambda_state.table.selected / page_size;
assert_eq!(current_page, 1);
app.lambda_state.table.selected = 99;
let current_page = app.lambda_state.table.selected / page_size;
assert_eq!(current_page, 1);
let filtered_count = app.lambda_state.table.items.len();
let total_pages = filtered_count.div_ceil(page_size);
assert_eq!(total_pages, 2);
}
#[test]
fn test_pagination_color_matches_border_color() {
use ratatui::style::{Color, Style};
let is_filter_input = false;
let pagination_style = if is_filter_input {
Style::default()
} else {
Style::default().fg(Color::Green)
};
let border_style = if is_filter_input {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
assert_eq!(pagination_style.fg, Some(Color::Green));
assert_eq!(border_style.fg, None);
let is_filter_input = true;
let pagination_style = if is_filter_input {
Style::default()
} else {
Style::default().fg(Color::Green)
};
let border_style = if is_filter_input {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
assert_eq!(pagination_style.fg, None); assert_eq!(border_style.fg, Some(Color::Yellow));
}
#[test]
fn test_lambda_application_expansion_indicator() {
let app_name = "my-application";
let collapsed = crate::ui::table::format_expandable(app_name, false);
assert!(collapsed.contains(crate::ui::table::CURSOR_COLLAPSED));
assert!(collapsed.contains(app_name));
let expanded = crate::ui::table::format_expandable(app_name, true);
assert!(expanded.contains(crate::ui::table::CURSOR_EXPANDED));
assert!(expanded.contains(app_name));
}
#[test]
fn test_ecr_repository_selection_uses_table_state_page_size() {
let mut app = test_app_no_region();
app.current_service = Service::EcrRepositories;
app.ecr_state.repositories.items = (0..100)
.map(|i| crate::ecr::repo::Repository {
name: format!("repo{}", i),
uri: format!("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo{}", i),
created_at: "2024-01-01".to_string(),
tag_immutability: "MUTABLE".to_string(),
encryption_type: "AES256".to_string(),
})
.collect();
app.ecr_state.repositories.page_size = crate::common::PageSize::TwentyFive;
app.ecr_state.repositories.selected = 30;
let page_size = app.ecr_state.repositories.page_size.value();
let selected_index = app.ecr_state.repositories.selected % page_size;
assert_eq!(page_size, 25);
assert_eq!(selected_index, 5); }
#[test]
fn test_ecr_repository_selection_indicator_visible() {
let mut app = test_app_no_region();
app.current_service = Service::EcrRepositories;
app.mode = crate::keymap::Mode::Normal;
app.ecr_state.repositories.items = vec![
crate::ecr::repo::Repository {
name: "repo1".to_string(),
uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo1".to_string(),
created_at: "2024-01-01".to_string(),
tag_immutability: "MUTABLE".to_string(),
encryption_type: "AES256".to_string(),
},
crate::ecr::repo::Repository {
name: "repo2".to_string(),
uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo2".to_string(),
created_at: "2024-01-02".to_string(),
tag_immutability: "IMMUTABLE".to_string(),
encryption_type: "KMS".to_string(),
},
];
app.ecr_state.repositories.selected = 1;
let page_size = app.ecr_state.repositories.page_size.value();
let selected_index = app.ecr_state.repositories.selected % page_size;
let is_active = app.mode != crate::keymap::Mode::FilterInput;
assert_eq!(selected_index, 1);
assert!(is_active);
}
#[test]
fn test_ecr_repository_shows_expandable_indicator() {
let repo = crate::ecr::repo::Repository {
name: "test-repo".to_string(),
uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
created_at: "2024-01-01".to_string(),
tag_immutability: "MUTABLE".to_string(),
encryption_type: "AES256".to_string(),
};
let collapsed = crate::ui::table::format_expandable(&repo.name, false);
assert!(collapsed.contains(crate::ui::table::CURSOR_COLLAPSED));
assert!(collapsed.contains("test-repo"));
let expanded = crate::ui::table::format_expandable(&repo.name, true);
assert!(expanded.contains(crate::ui::table::CURSOR_EXPANDED));
assert!(expanded.contains("test-repo"));
}
#[test]
fn test_lambda_application_expanded_status_formatting() {
let app = lambda::Application {
name: "test-app".to_string(),
arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-app/abc123".to_string(),
description: "Test application".to_string(),
status: "UpdateComplete".to_string(),
last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
};
let status_upper = app.status.to_uppercase();
let formatted = if status_upper.contains("UPDATECOMPLETE")
|| status_upper.contains("UPDATE_COMPLETE")
{
"✅ Update complete"
} else if status_upper.contains("CREATECOMPLETE")
|| status_upper.contains("CREATE_COMPLETE")
{
"✅ Create complete"
} else {
&app.status
};
assert_eq!(formatted, "✅ Update complete");
let app2 = lambda::Application {
status: "CreateComplete".to_string(),
..app
};
let status_upper = app2.status.to_uppercase();
let formatted = if status_upper.contains("UPDATECOMPLETE")
|| status_upper.contains("UPDATE_COMPLETE")
{
"✅ Update complete"
} else if status_upper.contains("CREATECOMPLETE")
|| status_upper.contains("CREATE_COMPLETE")
{
"✅ Create complete"
} else {
&app2.status
};
assert_eq!(formatted, "✅ Create complete");
}
#[test]
fn test_pagination_shows_1_when_empty() {
let result = render_pagination_text(0, 0);
assert_eq!(result, "[1]");
}
#[test]
fn test_pagination_shows_current_page() {
let result = render_pagination_text(0, 3);
assert_eq!(result, "[1] 2 3");
let result = render_pagination_text(1, 3);
assert_eq!(result, "1 [2] 3");
}
#[test]
fn test_cloudformation_section_heights_match_content() {
let overview_fields = 14;
let overview_height = overview_fields + 2;
assert_eq!(overview_height, 16);
let tags_empty_lines = 4;
let tags_empty_height = tags_empty_lines + 2;
assert_eq!(tags_empty_height, 6);
let policy_empty_lines = 5;
let policy_empty_height = policy_empty_lines + 2;
assert_eq!(policy_empty_height, 7);
let rollback_empty_lines = 6;
let rollback_empty_height = rollback_empty_lines + 2;
assert_eq!(rollback_empty_height, 8);
let notifications_empty_lines = 4;
let notifications_empty_height = notifications_empty_lines + 2;
assert_eq!(notifications_empty_height, 6);
}
#[test]
fn test_log_groups_uses_table_state() {
let mut app = test_app_no_region();
app.current_service = Service::CloudWatchLogGroups;
assert_eq!(app.log_groups_state.log_groups.items.len(), 0);
assert_eq!(app.log_groups_state.log_groups.selected, 0);
assert_eq!(app.log_groups_state.log_groups.filter, "");
assert_eq!(
app.log_groups_state.log_groups.page_size,
crate::common::PageSize::Fifty
);
}
#[test]
fn test_log_groups_filter_and_pagination() {
let mut app = test_app_no_region();
app.current_service = Service::CloudWatchLogGroups;
app.log_groups_state.log_groups.items = vec![
rusticity_core::LogGroup {
name: "/aws/lambda/function1".to_string(),
creation_time: None,
stored_bytes: Some(1024),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
},
rusticity_core::LogGroup {
name: "/aws/lambda/function2".to_string(),
creation_time: None,
stored_bytes: Some(2048),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
},
rusticity_core::LogGroup {
name: "/aws/ecs/service1".to_string(),
creation_time: None,
stored_bytes: Some(4096),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
},
];
app.log_groups_state.log_groups.filter = "lambda".to_string();
let filtered = filtered_log_groups(&app);
assert_eq!(filtered.len(), 2);
let page_size = app.log_groups_state.log_groups.page_size.value();
assert_eq!(page_size, 50);
}
#[test]
fn test_log_groups_expandable_indicators() {
let group = rusticity_core::LogGroup {
name: "/aws/lambda/test".to_string(),
creation_time: None,
stored_bytes: Some(1024),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
};
let collapsed = crate::ui::table::format_expandable(&group.name, false);
assert!(collapsed.starts_with("► "));
assert!(collapsed.contains("/aws/lambda/test"));
let expanded = crate::ui::table::format_expandable(&group.name, true);
assert!(expanded.starts_with("▼ "));
assert!(expanded.contains("/aws/lambda/test"));
}
#[test]
fn test_log_groups_visual_boundaries() {
assert_eq!(crate::ui::table::CURSOR_COLLAPSED, "►");
assert_eq!(crate::ui::table::CURSOR_EXPANDED, "▼");
let continuation = "│ ";
let last_line = "╰ ";
assert_eq!(continuation, "│ ");
assert_eq!(last_line, "╰ ");
}
#[test]
fn test_log_groups_right_arrow_expands() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::List;
app.log_groups_state.log_groups.items = vec![rusticity_core::LogGroup {
name: "/aws/lambda/test".to_string(),
creation_time: None,
stored_bytes: Some(1024),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
}];
app.log_groups_state.log_groups.selected = 0;
assert_eq!(app.log_groups_state.log_groups.expanded_item, None);
app.handle_action(Action::NextPane);
assert_eq!(app.log_groups_state.log_groups.expanded_item, Some(0));
app.handle_action(Action::PrevPane);
assert_eq!(app.log_groups_state.log_groups.expanded_item, None);
}
#[test]
fn test_log_streams_right_arrow_expands() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::Detail;
app.log_groups_state.log_streams = vec![rusticity_core::LogStream {
name: "stream-1".to_string(),
creation_time: None,
last_event_time: None,
}];
app.log_groups_state.selected_stream = 0;
assert_eq!(app.log_groups_state.expanded_stream, None);
app.handle_action(Action::NextPane);
assert_eq!(app.log_groups_state.expanded_stream, Some(0));
app.handle_action(Action::PrevPane);
assert_eq!(app.log_groups_state.expanded_stream, None);
}
#[test]
fn test_log_events_border_style_no_double_border() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::Events;
assert_eq!(app.view_mode, ViewMode::Events);
}
#[test]
fn test_log_group_detail_border_style_no_double_border() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::Detail;
assert_eq!(app.view_mode, ViewMode::Detail);
}
#[test]
fn test_expansion_uses_intermediate_field_indicator() {
let intermediate = "├ ";
let continuation = "│ ";
let last = "╰ ";
assert_eq!(intermediate, "├ ");
assert_eq!(continuation, "│ ");
assert_eq!(last, "╰ ");
}
#[test]
fn test_log_streams_expansion_renders() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::Detail;
app.log_groups_state.log_streams = vec![rusticity_core::LogStream {
name: "test-stream".to_string(),
creation_time: None,
last_event_time: None,
}];
app.log_groups_state.selected_stream = 0;
app.log_groups_state.expanded_stream = Some(0);
assert_eq!(app.log_groups_state.expanded_stream, Some(0));
assert_eq!(app.log_groups_state.log_streams.len(), 1);
assert_eq!(app.log_groups_state.log_streams[0].name, "test-stream");
}
#[test]
fn test_log_streams_filter_layout_single_line() {
let _app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
let expected_filter_height = 3;
assert_eq!(expected_filter_height, 3);
}
#[test]
fn test_table_navigation_at_page_boundary() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::List;
app.mode = Mode::Normal;
for i in 0..100 {
app.log_groups_state
.log_groups
.items
.push(rusticity_core::LogGroup {
name: format!("/aws/lambda/function{}", i),
creation_time: None,
stored_bytes: Some(1024),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
});
}
app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
app.log_groups_state.log_groups.selected = 49;
app.handle_action(Action::NextItem);
assert_eq!(app.log_groups_state.log_groups.selected, 50);
app.handle_action(Action::PrevItem);
assert_eq!(app.log_groups_state.log_groups.selected, 49);
app.handle_action(Action::NextItem);
assert_eq!(app.log_groups_state.log_groups.selected, 50);
app.handle_action(Action::PrevItem);
assert_eq!(app.log_groups_state.log_groups.selected, 49);
}
#[test]
fn test_table_navigation_at_end() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::List;
app.mode = Mode::Normal;
for i in 0..100 {
app.log_groups_state
.log_groups
.items
.push(rusticity_core::LogGroup {
name: format!("/aws/lambda/function{}", i),
creation_time: None,
stored_bytes: Some(1024),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
});
}
app.log_groups_state.log_groups.selected = 99;
app.handle_action(Action::NextItem);
assert_eq!(app.log_groups_state.log_groups.selected, 99);
app.handle_action(Action::PrevItem);
assert_eq!(app.log_groups_state.log_groups.selected, 98);
}
#[test]
fn test_table_viewport_scrolling() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::List;
app.mode = Mode::Normal;
for i in 0..100 {
app.log_groups_state
.log_groups
.items
.push(rusticity_core::LogGroup {
name: format!("/aws/lambda/function{}", i),
creation_time: None,
stored_bytes: Some(1024),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
});
}
app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
app.log_groups_state.log_groups.selected = 49;
app.log_groups_state.log_groups.scroll_offset = 0;
app.handle_action(Action::NextItem);
assert_eq!(app.log_groups_state.log_groups.selected, 50);
assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1);
app.handle_action(Action::PrevItem);
assert_eq!(app.log_groups_state.log_groups.selected, 49);
assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1);
app.handle_action(Action::PrevItem);
assert_eq!(app.log_groups_state.log_groups.selected, 48);
assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1);
for _ in 0..47 {
app.handle_action(Action::PrevItem);
}
assert_eq!(app.log_groups_state.log_groups.selected, 1);
assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1);
app.handle_action(Action::PrevItem);
assert_eq!(app.log_groups_state.log_groups.selected, 0);
assert_eq!(app.log_groups_state.log_groups.scroll_offset, 0); }
#[test]
fn test_table_up_from_last_row() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::List;
app.mode = Mode::Normal;
for i in 0..100 {
app.log_groups_state
.log_groups
.items
.push(rusticity_core::LogGroup {
name: format!("/aws/lambda/function{}", i),
creation_time: None,
stored_bytes: Some(1024),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
});
}
app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
app.log_groups_state.log_groups.selected = 99;
app.log_groups_state.log_groups.scroll_offset = 50;
app.handle_action(Action::PrevItem);
assert_eq!(app.log_groups_state.log_groups.selected, 98);
assert_eq!(app.log_groups_state.log_groups.scroll_offset, 50);
app.handle_action(Action::PrevItem);
assert_eq!(app.log_groups_state.log_groups.selected, 97);
assert_eq!(app.log_groups_state.log_groups.scroll_offset, 50); }
#[test]
fn test_table_up_from_last_visible_row() {
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::List;
app.mode = Mode::Normal;
for i in 0..100 {
app.log_groups_state
.log_groups
.items
.push(rusticity_core::LogGroup {
name: format!("/aws/lambda/function{}", i),
creation_time: None,
stored_bytes: Some(1024),
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
});
}
app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
app.log_groups_state.log_groups.selected = 49;
app.log_groups_state.log_groups.scroll_offset = 0;
app.handle_action(Action::NextItem);
assert_eq!(app.log_groups_state.log_groups.selected, 50);
assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1);
app.handle_action(Action::PrevItem);
assert_eq!(
app.log_groups_state.log_groups.selected, 49,
"Selection should move to 49"
);
assert_eq!(
app.log_groups_state.log_groups.scroll_offset, 1,
"Should NOT scroll up"
);
}
#[test]
fn test_cloudformation_up_from_last_visible_row() {
let mut app = test_app();
app.current_service = Service::CloudFormationStacks;
app.service_selected = true;
app.mode = Mode::Normal;
for i in 0..100 {
app.cfn_state.table.items.push(crate::cfn::Stack {
name: format!("Stack{}", i),
stack_id: format!("id{}", i),
status: "CREATE_COMPLETE".to_string(),
created_time: "2024-01-01 00:00:00 (UTC)".to_string(),
updated_time: "2024-01-01 00:00:00 (UTC)".to_string(),
deleted_time: String::new(),
description: "Test".to_string(),
drift_status: "NOT_CHECKED".to_string(),
last_drift_check_time: "-".to_string(),
status_reason: String::new(),
detailed_status: "CREATE_COMPLETE".to_string(),
root_stack: String::new(),
parent_stack: String::new(),
termination_protection: false,
iam_role: String::new(),
tags: Vec::new(),
stack_policy: String::new(),
rollback_monitoring_time: String::new(),
rollback_alarms: Vec::new(),
notification_arns: Vec::new(),
});
}
app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
app.cfn_state.table.selected = 49;
app.cfn_state.table.scroll_offset = 0;
app.handle_action(Action::NextItem);
assert_eq!(app.cfn_state.table.selected, 50);
assert_eq!(app.cfn_state.table.scroll_offset, 1);
app.handle_action(Action::PrevItem);
assert_eq!(
app.cfn_state.table.selected, 49,
"Selection should move to 49"
);
assert_eq!(
app.cfn_state.table.scroll_offset, 1,
"Should NOT scroll up - this is the bug!"
);
}
#[test]
fn test_cloudformation_up_from_actual_last_row() {
let mut app = test_app();
app.current_service = Service::CloudFormationStacks;
app.service_selected = true;
app.mode = Mode::Normal;
for i in 0..88 {
app.cfn_state.table.items.push(crate::cfn::Stack {
name: format!("Stack{}", i),
stack_id: format!("id{}", i),
status: "CREATE_COMPLETE".to_string(),
created_time: "2024-01-01 00:00:00 (UTC)".to_string(),
updated_time: "2024-01-01 00:00:00 (UTC)".to_string(),
deleted_time: String::new(),
description: "Test".to_string(),
drift_status: "NOT_CHECKED".to_string(),
last_drift_check_time: "-".to_string(),
status_reason: String::new(),
detailed_status: "CREATE_COMPLETE".to_string(),
root_stack: String::new(),
parent_stack: String::new(),
termination_protection: false,
iam_role: String::new(),
tags: Vec::new(),
stack_policy: String::new(),
rollback_monitoring_time: String::new(),
rollback_alarms: Vec::new(),
notification_arns: Vec::new(),
});
}
app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
app.cfn_state.table.selected = 87;
app.cfn_state.table.scroll_offset = 38;
app.handle_action(Action::PrevItem);
assert_eq!(
app.cfn_state.table.selected, 86,
"Selection should move to 86"
);
assert_eq!(
app.cfn_state.table.scroll_offset, 38,
"Should NOT scroll - scroll_offset should stay at 38"
);
}
#[test]
fn test_iam_users_default_columns() {
let app = test_app();
assert_eq!(app.iam_user_visible_column_ids.len(), 11);
assert!(app
.iam_user_visible_column_ids
.contains(&"column.iam.user.user_name"));
assert!(app
.iam_user_visible_column_ids
.contains(&"column.iam.user.path"));
assert!(app
.iam_user_visible_column_ids
.contains(&"column.iam.user.arn"));
}
#[test]
fn test_iam_users_all_columns() {
let app = test_app();
assert_eq!(app.iam_user_column_ids.len(), 14);
assert!(app
.iam_user_column_ids
.contains(&"column.iam.user.creation_time"));
assert!(app
.iam_user_column_ids
.contains(&"column.iam.user.console_access"));
assert!(app
.iam_user_column_ids
.contains(&"column.iam.user.signing_certs"));
}
#[test]
fn test_iam_users_filter() {
let mut app = test_app();
app.current_service = Service::IamUsers;
app.iam_state.users.items = vec![
crate::iam::IamUser {
user_name: "alice".to_string(),
path: "/".to_string(),
groups: "admins".to_string(),
last_activity: "2024-01-01".to_string(),
mfa: "Enabled".to_string(),
password_age: "30 days".to_string(),
console_last_sign_in: "2024-01-01".to_string(),
access_key_id: "AKIA...".to_string(),
active_key_age: "60 days".to_string(),
access_key_last_used: "2024-01-01".to_string(),
arn: "arn:aws:iam::123456789012:user/alice".to_string(),
creation_time: "2023-01-01".to_string(),
console_access: "Enabled".to_string(),
signing_certs: "0".to_string(),
},
crate::iam::IamUser {
user_name: "bob".to_string(),
path: "/".to_string(),
groups: "developers".to_string(),
last_activity: "2024-01-02".to_string(),
mfa: "Disabled".to_string(),
password_age: "45 days".to_string(),
console_last_sign_in: "2024-01-02".to_string(),
access_key_id: "AKIA...".to_string(),
active_key_age: "90 days".to_string(),
access_key_last_used: "2024-01-02".to_string(),
arn: "arn:aws:iam::123456789012:user/bob".to_string(),
creation_time: "2023-02-01".to_string(),
console_access: "Enabled".to_string(),
signing_certs: "1".to_string(),
},
];
let filtered = crate::ui::iam::filtered_iam_users(&app);
assert_eq!(filtered.len(), 2);
app.iam_state.users.filter = "alice".to_string();
let filtered = crate::ui::iam::filtered_iam_users(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].user_name, "alice");
app.iam_state.users.filter = "BOB".to_string();
let filtered = crate::ui::iam::filtered_iam_users(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].user_name, "bob");
}
#[test]
fn test_iam_users_pagination() {
let mut app = test_app();
app.current_service = Service::IamUsers;
for i in 0..30 {
app.iam_state.users.items.push(crate::iam::IamUser {
user_name: format!("user{}", i),
path: "/".to_string(),
groups: String::new(),
last_activity: "-".to_string(),
mfa: "Disabled".to_string(),
password_age: "-".to_string(),
console_last_sign_in: "-".to_string(),
access_key_id: "-".to_string(),
active_key_age: "-".to_string(),
access_key_last_used: "-".to_string(),
arn: format!("arn:aws:iam::123456789012:user/user{}", i),
creation_time: "2023-01-01".to_string(),
console_access: "Disabled".to_string(),
signing_certs: "0".to_string(),
});
}
app.iam_state.users.page_size = crate::common::PageSize::TwentyFive;
let filtered = crate::ui::iam::filtered_iam_users(&app);
assert_eq!(filtered.len(), 30);
let page_size = app.iam_state.users.page_size.value();
assert_eq!(page_size, 25);
}
#[test]
fn test_iam_users_expansion() {
let mut app = test_app();
app.current_service = Service::IamUsers;
app.service_selected = true;
app.mode = Mode::Normal;
app.iam_state.users.items = vec![crate::iam::IamUser {
user_name: "testuser".to_string(),
path: "/admin/".to_string(),
groups: "admins,developers".to_string(),
last_activity: "2024-01-01".to_string(),
mfa: "Enabled".to_string(),
password_age: "30 days".to_string(),
console_last_sign_in: "2024-01-01 10:00:00".to_string(),
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
active_key_age: "60 days".to_string(),
access_key_last_used: "2024-01-01 09:00:00".to_string(),
arn: "arn:aws:iam::123456789012:user/admin/testuser".to_string(),
creation_time: "2023-01-01 00:00:00".to_string(),
console_access: "Enabled".to_string(),
signing_certs: "2".to_string(),
}];
app.handle_action(Action::NextPane);
assert_eq!(app.iam_state.users.expanded_item, Some(0));
app.handle_action(Action::PrevPane);
assert_eq!(app.iam_state.users.expanded_item, None);
}
#[test]
fn test_iam_users_in_service_picker() {
let app = test_app();
assert!(app.service_picker.services.contains(&"IAM › Users"));
}
#[test]
fn test_iam_users_service_selection() {
let mut app = test_app();
app.mode = Mode::ServicePicker;
let filtered = app.filtered_services();
let selected_idx = filtered.iter().position(|&s| s == "IAM › Users").unwrap();
app.service_picker.selected = selected_idx;
app.handle_action(Action::Select);
assert_eq!(app.current_service, Service::IamUsers);
assert!(app.service_selected);
assert_eq!(app.tabs.len(), 1);
assert_eq!(app.tabs[0].service, Service::IamUsers);
assert_eq!(app.tabs[0].title, "IAM › Users");
}
#[test]
fn test_api_gateway_in_service_picker() {
let app = test_app();
assert!(app.service_picker.services.contains(&"API Gateway › APIs"));
}
#[test]
fn test_api_gateway_service_selection() {
let mut app = test_app();
app.mode = Mode::ServicePicker;
let filtered = app.filtered_services();
let selected_idx = filtered
.iter()
.position(|&s| s == "API Gateway › APIs")
.unwrap();
app.service_picker.selected = selected_idx;
app.handle_action(Action::Select);
assert_eq!(app.current_service, Service::ApiGatewayApis);
assert!(app.service_selected);
assert_eq!(app.tabs.len(), 1);
assert_eq!(app.tabs[0].service, Service::ApiGatewayApis);
assert_eq!(app.tabs[0].title, "API Gateway › APIs");
}
#[test]
fn test_format_duration_seconds() {
assert_eq!(format_duration(1), "1 second");
assert_eq!(format_duration(30), "30 seconds");
}
#[test]
fn test_format_duration_minutes() {
assert_eq!(format_duration(60), "1 minute");
assert_eq!(format_duration(120), "2 minutes");
assert_eq!(format_duration(3600 - 1), "59 minutes");
}
#[test]
fn test_format_duration_hours() {
assert_eq!(format_duration(3600), "1 hour");
assert_eq!(format_duration(7200), "2 hours");
assert_eq!(format_duration(3600 + 1800), "1 hour 30 minutes");
assert_eq!(format_duration(7200 + 60), "2 hours 1 minute");
}
#[test]
fn test_format_duration_days() {
assert_eq!(format_duration(86400), "1 day");
assert_eq!(format_duration(172800), "2 days");
assert_eq!(format_duration(86400 + 3600), "1 day 1 hour");
assert_eq!(format_duration(172800 + 7200), "2 days 2 hours");
}
#[test]
fn test_format_duration_weeks() {
assert_eq!(format_duration(604800), "1 week");
assert_eq!(format_duration(1209600), "2 weeks");
assert_eq!(format_duration(604800 + 86400), "1 week 1 day");
assert_eq!(format_duration(1209600 + 172800), "2 weeks 2 days");
}
#[test]
fn test_format_duration_years() {
assert_eq!(format_duration(31536000), "1 year");
assert_eq!(format_duration(63072000), "2 years");
assert_eq!(format_duration(31536000 + 604800), "1 year 1 week");
assert_eq!(format_duration(63072000 + 1209600), "2 years 2 weeks");
}
#[test]
fn test_tab_style_selected() {
let style = tab_style(true);
assert_eq!(style, highlight());
}
#[test]
fn test_tab_style_not_selected() {
let style = tab_style(false);
assert_eq!(style, Style::default());
}
#[test]
fn test_render_tab_spans_single_tab() {
let tabs = [("Tab1", true)];
let spans = render_tab_spans(&tabs);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "Tab1");
assert_eq!(spans[0].style, service_tab_style(true));
}
#[test]
fn test_render_tab_spans_multiple_tabs() {
let tabs = [("Tab1", true), ("Tab2", false), ("Tab3", false)];
let spans = render_tab_spans(&tabs);
assert_eq!(spans.len(), 5); assert_eq!(spans[0].content, "Tab1");
assert_eq!(spans[0].style, service_tab_style(true));
assert_eq!(spans[1].content, " ⋮ ");
assert_eq!(spans[2].content, "Tab2");
assert_eq!(spans[2].style, Style::default());
assert_eq!(spans[3].content, " ⋮ ");
assert_eq!(spans[4].content, "Tab3");
assert_eq!(spans[4].style, Style::default());
}
#[test]
fn test_render_tab_spans_no_separator_for_first() {
let tabs = [("First", false), ("Second", true)];
let spans = render_tab_spans(&tabs);
assert_eq!(spans.len(), 3); assert_eq!(spans[0].content, "First");
assert_eq!(spans[1].content, " ⋮ ");
assert_eq!(spans[2].content, "Second");
assert_eq!(spans[2].style, service_tab_style(true));
}
#[test]
fn test_calculate_dynamic_height_empty() {
let fields: Vec<Line> = vec![];
assert_eq!(calculate_dynamic_height(&fields, 100), 0);
}
#[test]
fn test_calculate_dynamic_height_single_column() {
let fields = vec![
Line::from("Field 1"),
Line::from("Field 2"),
Line::from("Field 3"),
];
assert_eq!(calculate_dynamic_height(&fields, 30), 1);
}
#[test]
fn test_calculate_dynamic_height_two_columns() {
let fields = vec![
Line::from("Field 1"),
Line::from("Field 2"),
Line::from("Field 3"),
Line::from("Field 4"),
Line::from("Field 5"),
];
assert_eq!(calculate_dynamic_height(&fields, 20), 3);
}
#[test]
fn test_calculate_dynamic_height_three_columns() {
let fields = vec![
Line::from("F1"),
Line::from("F2"),
Line::from("F3"),
Line::from("F4"),
Line::from("F5"),
Line::from("F6"),
Line::from("F7"),
Line::from("F8"),
Line::from("F9"),
Line::from("F10"),
];
let result = calculate_dynamic_height(&fields, 20);
assert_eq!(result, 4);
}
#[test]
fn test_calculate_dynamic_height_even_distribution() {
let fields = vec![
Line::from("A"),
Line::from("B"),
Line::from("C"),
Line::from("D"),
];
assert_eq!(calculate_dynamic_height(&fields, 100), 2);
}
#[test]
fn test_ec2_tags_preferences_shows_tag_columns() {
let mut app = crate::app::App::new_without_client("default".to_string(), None);
app.current_service = crate::app::Service::Ec2Instances;
app.ec2_state.current_instance = Some("i-123".to_string());
app.ec2_state.detail_tab = ec2::DetailTab::Tags;
app.mode = crate::keymap::Mode::ColumnSelector;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(f, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let content = buffer
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(content.contains("Key"));
assert!(content.contains("Value"));
assert!(!content.contains("Log stream"));
}
#[test]
fn test_cloudwatch_detail_preferences_shows_stream_columns() {
let mut app = crate::app::App::new_without_client("default".to_string(), None);
app.current_service = crate::app::Service::CloudWatchLogGroups;
app.view_mode = crate::app::ViewMode::Detail;
app.mode = crate::keymap::Mode::ColumnSelector;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(f, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let content = buffer
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(content.contains("Log stream"));
}
#[test]
fn test_ec2_instances_preferences_shows_instance_columns() {
let mut app = crate::app::App::new_without_client("default".to_string(), None);
app.current_service = crate::app::Service::Ec2Instances;
app.mode = crate::keymap::Mode::ColumnSelector;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(f, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let content = buffer
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(content.contains("Columns"));
assert!(!content.contains("Log stream"));
}
#[test]
fn test_section_header_has_leading_dash() {
let line = section_header("Default encryption", 50);
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(text.starts_with("─ "));
assert!(text.contains("Default encryption"));
assert!(text.ends_with('─'));
}
#[test]
fn test_section_header_width_calculation() {
let width = 60;
let line = section_header("Test Section", width);
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert_eq!(text.chars().count(), width as usize);
assert!(text.starts_with("─ Test Section "));
}
#[test]
fn test_cloudwatch_log_groups_no_breadcrumb_in_detail_view() {
use crate::app::{Service, ViewMode};
use rusticity_core::LogGroup;
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::Detail;
app.tabs.push(crate::app::Tab {
service: Service::CloudWatchLogGroups,
title: "CloudWatch › Log Groups".to_string(),
breadcrumb: "CloudWatch › Log Groups".to_string(),
});
app.log_groups_state.log_groups.items = vec![LogGroup {
name: "/aws/lambda/test".to_string(),
creation_time: None,
stored_bytes: None,
retention_days: None,
log_class: None,
arn: None,
log_group_arn: None,
deletion_protection_enabled: None,
}];
let show_breadcrumbs = !app.tabs.is_empty()
&& app.service_selected
&& match app.current_service {
Service::S3Buckets => app.s3_state.current_bucket.is_some(),
_ => false,
};
assert!(!show_breadcrumbs);
}
#[test]
fn test_cloudwatch_log_groups_no_breadcrumb_in_events_view() {
use crate::app::{Service, ViewMode};
let mut app = test_app();
app.current_service = Service::CloudWatchLogGroups;
app.service_selected = true;
app.view_mode = ViewMode::Events;
app.tabs.push(crate::app::Tab {
service: Service::CloudWatchLogGroups,
title: "CloudWatch › Log Groups".to_string(),
breadcrumb: "CloudWatch › Log Groups".to_string(),
});
let show_breadcrumbs = !app.tabs.is_empty()
&& app.service_selected
&& match app.current_service {
Service::S3Buckets => app.s3_state.current_bucket.is_some(),
_ => false,
};
assert!(!show_breadcrumbs);
}
#[test]
fn test_title_format_with_leading_dash() {
let title = format_title("APIs (2)");
assert_eq!(title, "─ APIs (2) ─");
let title = format_title("Preferences");
assert_eq!(title, "─ Preferences ─");
let title = format_title("CloudTrail Events");
assert_eq!(title, "─ CloudTrail Events ─");
let title = format_title("AWS Services");
assert_eq!(title, "─ AWS Services ─");
}
#[test]
fn test_titled_block_renders_correctly() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(40, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let block = titled_block("AWS Services");
let area = frame.area();
frame.render_widget(block, area);
})
.unwrap();
let buffer = terminal.backend().buffer();
let first_line = buffer.content()[0..40]
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(
first_line.starts_with("╭─ AWS Services ─"),
"Expected '╭─ AWS Services ─' but got '{}'",
first_line
);
}
#[test]
fn test_titled_block_cloudtrail_renders_correctly() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(50, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let block = titled_block("CloudTrail Events");
let area = frame.area();
frame.render_widget(block, area);
})
.unwrap();
let buffer = terminal.backend().buffer();
let first_line = buffer.content()[0..50]
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(
first_line.starts_with("╭─ CloudTrail Events ─"),
"Expected '╭─ CloudTrail Events ─' but got '{}'",
first_line
);
}
#[test]
fn test_yaml_scalar_coloring_boolean() {
let spans = highlight_yaml_scalar("true");
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "true");
assert_eq!(spans[0].style.fg, Some(Color::Yellow));
let spans = highlight_yaml_scalar("false");
assert_eq!(spans[0].style.fg, Some(Color::Yellow));
let spans = highlight_yaml_scalar("null");
assert_eq!(spans[0].style.fg, Some(Color::DarkGray));
let spans = highlight_yaml_scalar("~");
assert_eq!(spans[0].style.fg, Some(Color::Yellow));
}
#[test]
fn test_yaml_scalar_coloring_number() {
let spans = highlight_yaml_scalar("42");
assert_eq!(spans[0].style.fg, Some(Color::Magenta));
let spans = highlight_yaml_scalar("3.14");
assert_eq!(spans[0].style.fg, Some(Color::Magenta));
}
#[test]
fn test_yaml_scalar_coloring_string() {
let spans = highlight_yaml_scalar("\"hello world\"");
assert_eq!(spans[0].style.fg, Some(Color::Green));
let spans = highlight_yaml_scalar("'single quoted'");
assert_eq!(spans[0].style.fg, Some(Color::Green));
}
#[test]
fn test_yaml_line_key_value() {
let spans = highlight_yaml_line("myKey: true");
assert!(
spans.iter().any(|s| s.style.fg == Some(Color::Blue)),
"Key should be colored blue"
);
assert!(
spans.iter().any(|s| s.style.fg == Some(Color::Yellow)),
"Boolean value true should be yellow"
);
}
#[test]
fn test_yaml_line_comment() {
let spans = highlight_yaml_line("# this is a comment");
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].style.fg, Some(Color::DarkGray));
}
#[test]
fn test_yaml_line_list_item() {
let spans = highlight_yaml_line("- itemValue");
assert_eq!(spans[0].content, "- ");
assert_eq!(spans[0].style.fg, Some(Color::Cyan));
}
#[test]
fn test_yaml_line_document_marker() {
let spans = highlight_yaml_line("---");
assert_eq!(spans[0].style.fg, Some(Color::DarkGray));
}
#[test]
fn test_render_template_highlighted_detects_json() {
let json = r#"{"AWSTemplateFormatVersion": "2010-09-09"}"#;
let trimmed = json.trim_start();
assert!(trimmed.starts_with('{'), "JSON detection should see '{{'");
let yaml = "AWSTemplateFormatVersion: '2010-09-09'";
let trimmed = yaml.trim_start();
assert!(
!trimmed.starts_with('{') && !trimmed.starts_with('['),
"YAML should not be detected as JSON"
);
}
#[test]
fn test_render_summary_uses_dynamic_columns() {
use ratatui::text::Line;
let fields: Vec<(&str, String)> = vec![
("ARN: ", "arn:aws:iam::123456789012:role/MyRole".to_string()),
(
"Trusted entities: ",
"Service: lambda.amazonaws.com".to_string(),
),
("Max session duration: ", "1 hour".to_string()),
("Created: ", "2024-01-01T00:00:00Z".to_string()),
("Description: ", "My role description".to_string()),
];
let lines: Vec<Line> = fields
.iter()
.map(|(label, value)| labeled_field(label, value.clone()))
.collect();
let narrow_height = calculate_dynamic_height(&lines, 40);
let wide_height = calculate_dynamic_height(&lines, 400);
assert!(
narrow_height >= wide_height,
"Wide layout should use as few or fewer rows than narrow: wide={wide_height} narrow={narrow_height}"
);
}
}