1pub mod cfn;
2pub mod cw;
3pub mod ec2;
4pub mod ecr;
5mod expanded_view;
6pub mod filter;
7pub mod iam;
8pub mod lambda;
9pub mod monitoring;
10mod pagination;
11pub mod prefs;
12mod query_editor;
13pub mod s3;
14pub mod sqs;
15mod status;
16pub mod styles;
17pub mod table;
18
19pub use cw::insights::{DateRangeType, TimeUnit};
20pub use cw::{
21 CloudWatchLogGroupsState, DetailTab, EventColumn, EventFilterFocus, LogGroupColumn,
22 StreamColumn, StreamSort,
23};
24pub use expanded_view::{format_expansion_text, format_fields};
25pub use pagination::{render_paginated_filter, PaginatedFilterConfig};
26pub use prefs::Preferences;
27pub use query_editor::{render_query_editor, QueryEditorConfig};
28pub use status::{first_hint, hint, last_hint, SPINNER_FRAMES};
29pub use table::{format_expandable, CURSOR_COLLAPSED, CURSOR_EXPANDED};
30
31pub const PAGE_SIZE_OPTIONS: &[(PageSize, &str)] = &[
32 (PageSize::Ten, "10"),
33 (PageSize::TwentyFive, "25"),
34 (PageSize::Fifty, "50"),
35 (PageSize::OneHundred, "100"),
36];
37
38pub const PAGE_SIZE_OPTIONS_SMALL: &[(PageSize, &str)] = &[
39 (PageSize::Ten, "10"),
40 (PageSize::TwentyFive, "25"),
41 (PageSize::Fifty, "50"),
42];
43
44pub const MAX_DETAIL_COLUMNS: usize = 3;
45
46use self::styles::highlight;
47use crate::app::{AlarmViewMode, App, CalendarField, LambdaDetailTab, Service, ViewMode};
48use crate::cfn::Column as CfnColumn;
49use crate::common::{render_pagination_text, render_scrollbar, translate_column, PageSize};
50use crate::cw::alarms::AlarmColumn;
51use crate::ec2::Column as Ec2Column;
52use crate::ecr::{image, repo};
53use crate::iam::{RoleColumn, UserColumn};
54use crate::keymap::Mode;
55use crate::lambda::{ApplicationColumn, DeploymentColumn, FunctionColumn, ResourceColumn};
56use crate::s3::BucketColumn;
57use crate::sqs::pipe::Column as SqsPipeColumn;
58use crate::sqs::queue::Column as SqsColumn;
59use crate::sqs::sub::Column as SqsSubscriptionColumn;
60use crate::sqs::tag::Column as SqsTagColumn;
61use crate::sqs::trigger::Column as SqsTriggerColumn;
62use crate::ui::cfn::{
63 DetailTab as CfnDetailTab, OutputColumn, ParameterColumn, ResourceColumn as CfnResourceColumn,
64};
65use crate::ui::iam::{RoleTab, UserTab};
66use crate::ui::lambda::ApplicationDetailTab;
67use crate::ui::sqs::QueueDetailTab as SqsQueueDetailTab;
68use crate::ui::table::Column as TableColumn;
69use ratatui::style::{Modifier, Style};
70use ratatui::text::{Line, Span};
71
72pub fn labeled_field(label: &str, value: impl Into<String>) -> Line<'static> {
73 let val = value.into();
74 let display = if val.is_empty() { "-".to_string() } else { val };
75 Line::from(vec![
76 Span::styled(
77 format!("{}: ", label),
78 Style::default().add_modifier(Modifier::BOLD),
79 ),
80 Span::raw(display),
81 ])
82}
83
84pub fn block_height(lines: &[Line]) -> u16 {
86 lines.len() as u16 + 2
87}
88
89pub fn block_height_for(line_count: usize) -> u16 {
91 line_count as u16 + 2
92}
93
94pub fn section_header(text: &str, width: u16) -> Line<'static> {
95 let text_len = text.len() as u16;
96 let remaining = width.saturating_sub(text_len + 3);
99 let dashes = "─".repeat(remaining as usize);
100 Line::from(vec![
101 Span::raw("─ "),
102 Span::raw(text.to_string()),
103 Span::raw(format!(" {}", dashes)),
104 ])
105}
106
107pub fn tab_style(selected: bool) -> Style {
108 if selected {
109 highlight()
110 } else {
111 Style::default()
112 }
113}
114
115pub fn service_tab_style(selected: bool) -> Style {
116 if selected {
117 Style::default().bg(Color::Green).fg(Color::Black)
118 } else {
119 Style::default()
120 }
121}
122
123pub fn render_tab_spans<'a>(tabs: &[(&'a str, bool)]) -> Vec<Span<'a>> {
124 let mut spans = Vec::new();
125 for (i, (name, selected)) in tabs.iter().enumerate() {
126 if i > 0 {
127 spans.push(Span::raw(" ⋮ "));
128 }
129 spans.push(Span::styled(*name, service_tab_style(*selected)));
130 }
131 spans
132}
133
134use ratatui::{prelude::*, widgets::*};
135
136pub const SEARCH_ICON: &str = "─ 🔍 ";
138pub const PREFERENCES_TITLE: &str = "Preferences";
139
140pub fn filter_area(filter_text: Vec<Span<'_>>, is_active: bool) -> Paragraph<'_> {
142 Paragraph::new(Line::from(filter_text))
143 .block(
144 Block::default()
145 .title(SEARCH_ICON)
146 .borders(Borders::ALL)
147 .border_type(BorderType::Rounded)
148 .border_type(BorderType::Rounded)
149 .border_type(BorderType::Rounded)
150 .border_style(if is_active {
151 active_border()
152 } else {
153 Style::default()
154 }),
155 )
156 .style(Style::default())
157}
158
159pub fn active_border() -> Style {
161 Style::default().fg(Color::Green)
162}
163
164pub fn rounded_block() -> Block<'static> {
165 Block::default()
166 .borders(Borders::ALL)
167 .border_type(BorderType::Rounded)
168 .border_type(BorderType::Rounded)
169 .border_type(BorderType::Rounded)
170}
171
172pub fn format_title(title: &str) -> String {
173 format!("─ {} ", title.trim())
174}
175
176pub fn titled_block(title: impl Into<String>) -> Block<'static> {
177 rounded_block().title(format_title(&title.into()))
178}
179
180pub fn titled_rounded_block(title: &'static str) -> Block<'static> {
181 titled_block(title)
182}
183
184pub fn bold_style() -> Style {
185 Style::default().add_modifier(Modifier::BOLD)
186}
187
188pub fn cyan_bold() -> Style {
189 Style::default()
190 .fg(Color::Cyan)
191 .add_modifier(Modifier::BOLD)
192}
193
194pub fn red_text() -> Style {
195 Style::default().fg(Color::Rgb(255, 165, 0))
196}
197
198pub fn yellow_text() -> Style {
199 Style::default().fg(Color::Yellow)
200}
201
202pub fn get_cursor(active: bool) -> &'static str {
203 if active {
204 "█"
205 } else {
206 ""
207 }
208}
209
210pub fn render_search_filter(
211 frame: &mut Frame,
212 area: Rect,
213 filter_text: &str,
214 is_active: bool,
215 selected: usize,
216 total_items: usize,
217 page_size: usize,
218) {
219 let cursor = get_cursor(is_active);
220 let total_pages = total_items.div_ceil(page_size);
221 let current_page = selected / page_size;
222 let pagination = render_pagination_text(current_page, total_pages);
223
224 let controls_text = format!(" {}", pagination);
225 let filter_width = (area.width as usize).saturating_sub(4);
226 let content_len = filter_text.len() + if is_active { cursor.len() } else { 0 };
227 let available_space = filter_width.saturating_sub(controls_text.len() + 1);
228
229 let mut spans = vec![];
230 if filter_text.is_empty() && !is_active {
231 spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
232 } else {
233 spans.push(Span::raw(filter_text));
234 }
235 if is_active {
236 spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
237 }
238 if content_len < available_space {
239 spans.push(Span::raw(
240 " ".repeat(available_space.saturating_sub(content_len)),
241 ));
242 }
243 spans.push(Span::styled(
244 controls_text,
245 if is_active {
246 Style::default()
247 } else {
248 Style::default().fg(Color::Green)
249 },
250 ));
251
252 let filter = filter_area(spans, is_active);
253 frame.render_widget(filter, area);
254}
255
256fn render_toggle(is_on: bool) -> Vec<Span<'static>> {
257 if is_on {
258 vec![
259 Span::styled("◼", Style::default().fg(Color::Blue)),
260 Span::raw("⬜"),
261 ]
262 } else {
263 vec![
264 Span::raw("⬜"),
265 Span::styled("◼", Style::default().fg(Color::Black)),
266 ]
267 }
268}
269
270fn render_radio(is_selected: bool) -> (String, Style) {
271 if is_selected {
272 ("●".to_string(), Style::default().fg(Color::Blue))
273 } else {
274 ("○".to_string(), Style::default())
275 }
276}
277
278pub fn vertical(
282 constraints: impl IntoIterator<Item = Constraint>,
283 area: Rect,
284) -> std::rc::Rc<[Rect]> {
285 Layout::default()
286 .direction(Direction::Vertical)
287 .constraints(constraints)
288 .split(area)
289}
290
291pub fn horizontal(
292 constraints: impl IntoIterator<Item = Constraint>,
293 area: Rect,
294) -> std::rc::Rc<[Rect]> {
295 Layout::default()
296 .direction(Direction::Horizontal)
297 .constraints(constraints)
298 .split(area)
299}
300
301pub fn block(title: &str) -> Block<'_> {
303 rounded_block().title(title)
304}
305
306pub fn block_with_style(title: &str, style: Style) -> Block<'_> {
307 titled_block(title).border_style(style)
308}
309
310pub fn render_fields_with_dynamic_columns(frame: &mut Frame, area: Rect, fields: Vec<Line>) -> u16 {
313 use ratatui::widgets::Paragraph;
314
315 if fields.is_empty() {
316 return 0;
317 }
318
319 let field_widths: Vec<u16> = fields
321 .iter()
322 .map(|line| {
323 line.spans
324 .iter()
325 .map(|span| span.content.len() as u16)
326 .sum::<u16>()
327 + 2
328 })
329 .collect();
330
331 let max_field_width = *field_widths.iter().max().unwrap_or(&20);
332 let available_width = area.width;
333
334 let num_columns = (available_width / max_field_width)
336 .max(1)
337 .min(MAX_DETAIL_COLUMNS as u16)
338 .min(fields.len() as u16) as usize;
339
340 let total_fields = fields.len();
342 let base_per_column = total_fields / num_columns;
343 let extra = total_fields % num_columns;
344
345 let mut columns: Vec<Vec<Line>> = Vec::new();
346 let mut field_idx = 0;
347
348 for col in 0..num_columns {
349 let fields_in_this_col = if col < extra {
350 base_per_column + 1
351 } else {
352 base_per_column
353 };
354
355 let mut column_fields = Vec::new();
356 for _ in 0..fields_in_this_col {
357 if field_idx < fields.len() {
358 column_fields.push(fields[field_idx].clone());
359 field_idx += 1;
360 }
361 }
362 columns.push(column_fields);
363 }
364
365 let max_rows = columns.iter().map(|c| c.len()).max().unwrap_or(1) as u16;
367
368 let constraints: Vec<Constraint> = (0..num_columns)
370 .map(|_| Constraint::Percentage(100 / num_columns as u16))
371 .collect();
372
373 let column_layout = Layout::default()
374 .direction(Direction::Horizontal)
375 .constraints(constraints)
376 .split(area);
377
378 for (i, column_fields) in columns.iter().enumerate() {
380 if i < column_layout.len() {
381 frame.render_widget(Paragraph::new(column_fields.clone()), column_layout[i]);
382 }
383 }
384
385 max_rows
386}
387
388pub fn calculate_dynamic_height(fields: &[Line], width: u16) -> u16 {
390 if fields.is_empty() {
391 return 0;
392 }
393
394 let field_widths: Vec<u16> = fields
395 .iter()
396 .map(|line| {
397 line.spans
398 .iter()
399 .map(|span| span.content.len() as u16)
400 .sum::<u16>()
401 + 2
402 })
403 .collect();
404
405 let max_field_width = *field_widths.iter().max().unwrap_or(&20);
406 let num_columns = (width / max_field_width)
407 .max(1)
408 .min(MAX_DETAIL_COLUMNS as u16)
409 .min(fields.len() as u16) as usize;
410
411 let base = fields.len() / num_columns;
412 let extra = fields.len() % num_columns;
413 let max_rows = if extra > 0 { base + 1 } else { base };
414
415 max_rows as u16
416}
417
418pub fn render_summary(frame: &mut Frame, area: Rect, title: &str, fields: &[(&str, String)]) {
420 let summary_block = titled_block(title);
421 let inner = summary_block.inner(area);
422 frame.render_widget(summary_block, area);
423
424 let lines: Vec<Line> = fields
425 .iter()
426 .map(|(label, value)| {
427 Line::from(vec![
428 Span::styled(*label, Style::default().add_modifier(Modifier::BOLD)),
429 Span::raw(value),
430 ])
431 })
432 .collect();
433
434 frame.render_widget(Paragraph::new(lines), inner);
435}
436
437pub fn render_tabs<T: PartialEq>(frame: &mut Frame, area: Rect, tabs: &[(&str, T)], selected: &T) {
439 let spans: Vec<Span> = tabs
440 .iter()
441 .enumerate()
442 .flat_map(|(i, (name, tab))| {
443 let mut result = Vec::new();
444 if i > 0 {
445 result.push(Span::raw(" ⋮ "));
446 }
447 if tab == selected {
448 result.push(Span::styled(*name, tab_style(true)));
449 } else {
450 result.push(Span::raw(*name));
451 }
452 result
453 })
454 .collect();
455
456 frame.render_widget(Paragraph::new(Line::from(spans)), area);
457}
458
459pub fn format_duration(seconds: u64) -> String {
460 const MINUTE: u64 = 60;
461 const HOUR: u64 = 60 * MINUTE;
462 const DAY: u64 = 24 * HOUR;
463 const WEEK: u64 = 7 * DAY;
464 const YEAR: u64 = 365 * DAY;
465
466 if seconds >= YEAR {
467 let years = seconds / YEAR;
468 let remainder = seconds % YEAR;
469 if remainder == 0 {
470 format!("{} year{}", years, if years == 1 { "" } else { "s" })
471 } else {
472 let weeks = remainder / WEEK;
473 format!(
474 "{} year{} {} week{}",
475 years,
476 if years == 1 { "" } else { "s" },
477 weeks,
478 if weeks == 1 { "" } else { "s" }
479 )
480 }
481 } else if seconds >= WEEK {
482 let weeks = seconds / WEEK;
483 let remainder = seconds % WEEK;
484 if remainder == 0 {
485 format!("{} week{}", weeks, if weeks == 1 { "" } else { "s" })
486 } else {
487 let days = remainder / DAY;
488 format!(
489 "{} week{} {} day{}",
490 weeks,
491 if weeks == 1 { "" } else { "s" },
492 days,
493 if days == 1 { "" } else { "s" }
494 )
495 }
496 } else if seconds >= DAY {
497 let days = seconds / DAY;
498 let remainder = seconds % DAY;
499 if remainder == 0 {
500 format!("{} day{}", days, if days == 1 { "" } else { "s" })
501 } else {
502 let hours = remainder / HOUR;
503 format!(
504 "{} day{} {} hour{}",
505 days,
506 if days == 1 { "" } else { "s" },
507 hours,
508 if hours == 1 { "" } else { "s" }
509 )
510 }
511 } else if seconds >= HOUR {
512 let hours = seconds / HOUR;
513 let remainder = seconds % HOUR;
514 if remainder == 0 {
515 format!("{} hour{}", hours, if hours == 1 { "" } else { "s" })
516 } else {
517 let minutes = remainder / MINUTE;
518 format!(
519 "{} hour{} {} minute{}",
520 hours,
521 if hours == 1 { "" } else { "s" },
522 minutes,
523 if minutes == 1 { "" } else { "s" }
524 )
525 }
526 } else if seconds >= MINUTE {
527 let minutes = seconds / MINUTE;
528 format!("{} minute{}", minutes, if minutes == 1 { "" } else { "s" })
529 } else {
530 format!("{} second{}", seconds, if seconds == 1 { "" } else { "s" })
531 }
532}
533
534fn render_column_toggle_string(col_name: &str, is_visible: bool) -> (ListItem<'static>, usize) {
535 let mut spans = vec![];
536 spans.extend(render_toggle(is_visible));
537 spans.push(Span::raw(" "));
538 spans.push(Span::raw(col_name.to_string()));
539 let text_len = 4 + col_name.len();
540 (ListItem::new(Line::from(spans)), text_len)
541}
542
543fn render_section_header(title: &str) -> (ListItem<'static>, usize) {
545 let len = title.len();
546 (
547 ListItem::new(Line::from(Span::styled(
548 title.to_string(),
549 Style::default()
550 .fg(Color::Cyan)
551 .add_modifier(Modifier::BOLD),
552 ))),
553 len,
554 )
555}
556
557fn render_radio_item(label: &str, is_selected: bool, indent: bool) -> (ListItem<'static>, usize) {
559 let (radio, style) = render_radio(is_selected);
560 let text_len = (if indent { 2 } else { 0 }) + radio.chars().count() + 1 + label.len();
561 let mut spans = if indent {
562 vec![Span::raw(" ")]
563 } else {
564 vec![]
565 };
566 spans.push(Span::styled(radio, style));
567 spans.push(Span::raw(format!(" {}", label)));
568 (ListItem::new(Line::from(spans)), text_len)
569}
570
571fn render_page_size_section(
573 current_size: PageSize,
574 sizes: &[(PageSize, &str)],
575) -> (Vec<ListItem<'static>>, usize) {
576 let mut items = Vec::new();
577 let mut max_len = 0;
578
579 let (header, header_len) = render_section_header("Page size");
580 items.push(header);
581 max_len = max_len.max(header_len);
582
583 for (size, label) in sizes {
584 let is_selected = current_size == *size;
585 let (item, len) = render_radio_item(label, is_selected, false);
586 items.push(item);
587 max_len = max_len.max(len);
588 }
589
590 (items, max_len)
591}
592
593pub fn render(frame: &mut Frame, app: &App) {
594 let area = frame.area();
595
596 let has_tabs = !app.tabs.is_empty();
598 let show_breadcrumbs = has_tabs && app.service_selected && {
599 match app.current_service {
601 Service::CloudWatchLogGroups => app.view_mode != ViewMode::List,
602 Service::S3Buckets => app.s3_state.current_bucket.is_some(),
603 _ => false,
604 }
605 };
606
607 let chunks = if show_breadcrumbs {
608 Layout::default()
609 .direction(Direction::Vertical)
610 .constraints([
611 Constraint::Length(2), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
616 .split(area)
617 } else {
618 Layout::default()
619 .direction(Direction::Vertical)
620 .constraints([
621 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), ])
625 .split(area)
626 };
627
628 render_tabs_row(frame, app, chunks[0]);
630
631 if show_breadcrumbs {
632 render_top_bar(frame, app, chunks[1]);
633 }
634
635 let content_idx = if show_breadcrumbs { 2 } else { 1 };
636 let bottom_idx = if show_breadcrumbs { 3 } else { 2 };
637
638 if !app.service_selected && app.tabs.is_empty() && app.mode == Mode::Normal {
639 let message = vec![
641 Line::from(""),
642 Line::from(""),
643 Line::from(vec![
644 Span::raw("Press "),
645 Span::styled("␣", Style::default().fg(Color::Red)),
646 Span::raw(" to open Menu"),
647 ]),
648 ];
649 let paragraph = Paragraph::new(message).alignment(Alignment::Center);
650 frame.render_widget(paragraph, chunks[content_idx]);
651 render_bottom_bar(frame, app, chunks[bottom_idx]);
652 } else if !app.service_selected && app.mode == Mode::Normal {
653 render_service_picker(frame, app, chunks[content_idx]);
654 render_bottom_bar(frame, app, chunks[bottom_idx]);
655 } else if app.service_selected {
656 render_service(frame, app, chunks[content_idx]);
657 render_bottom_bar(frame, app, chunks[bottom_idx]);
658 } else {
659 render_bottom_bar(frame, app, chunks[bottom_idx]);
661 }
662
663 match app.mode {
665 Mode::SpaceMenu => render_space_menu(frame, area),
666 Mode::ServicePicker => render_service_picker(frame, app, area),
667 Mode::ColumnSelector => render_column_selector(frame, app, area),
668 Mode::ErrorModal => render_error_modal(frame, app, area),
669 Mode::HelpModal => render_help_modal(frame, area),
670 Mode::RegionPicker => render_region_selector(frame, app, area),
671 Mode::ProfilePicker => render_profile_picker(frame, app, area),
672 Mode::CalendarPicker => render_calendar_picker(frame, app, area),
673 Mode::TabPicker => render_tab_picker(frame, app, area),
674 Mode::SessionPicker => render_session_picker(frame, app, area),
675 _ => {}
676 }
677}
678
679fn render_tabs_row(frame: &mut Frame, app: &App, area: Rect) {
680 let chunks = Layout::default()
682 .direction(Direction::Vertical)
683 .constraints([Constraint::Length(1), Constraint::Length(1)])
684 .split(area);
685
686 let now = chrono::Utc::now();
688 let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
689
690 let (identity_label, identity_value) = if app.config.role_arn.is_empty() {
691 ("Identity:", "N/A".to_string())
692 } else if let Some(role_part) = app.config.role_arn.split("assumed-role/").nth(1) {
693 (
694 "Role:",
695 role_part.split('/').next().unwrap_or("N/A").to_string(),
696 )
697 } else if let Some(user_part) = app.config.role_arn.split(":user/").nth(1) {
698 ("User:", user_part.to_string())
699 } else {
700 ("Identity:", "N/A".to_string())
701 };
702
703 let region_display = if app.config.region_auto_detected {
704 format!(" {} ⚡ ⋮ ", app.config.region)
705 } else {
706 format!(" {} ⋮ ", app.config.region)
707 };
708
709 let info_spans = vec![
710 Span::styled(
711 "Profile:",
712 Style::default()
713 .fg(Color::White)
714 .add_modifier(Modifier::BOLD),
715 ),
716 Span::styled(
717 format!(" {} ⋮ ", app.profile),
718 Style::default().fg(Color::White),
719 ),
720 Span::styled(
721 "Account:",
722 Style::default()
723 .fg(Color::White)
724 .add_modifier(Modifier::BOLD),
725 ),
726 Span::styled(
727 format!(" {} ⋮ ", app.config.account_id),
728 Style::default().fg(Color::White),
729 ),
730 Span::styled(
731 "Region:",
732 Style::default()
733 .fg(Color::White)
734 .add_modifier(Modifier::BOLD),
735 ),
736 Span::styled(region_display, Style::default().fg(Color::White)),
737 Span::styled(
738 identity_label,
739 Style::default()
740 .fg(Color::White)
741 .add_modifier(Modifier::BOLD),
742 ),
743 Span::styled(
744 format!(" {} ⋮ ", identity_value),
745 Style::default().fg(Color::White),
746 ),
747 Span::styled(
748 "Timestamp:",
749 Style::default()
750 .fg(Color::White)
751 .add_modifier(Modifier::BOLD),
752 ),
753 Span::styled(
754 format!(" {} (UTC)", timestamp),
755 Style::default().fg(Color::White),
756 ),
757 ];
758
759 let info_widget = Paragraph::new(Line::from(info_spans))
760 .alignment(Alignment::Right)
761 .style(Style::default().bg(Color::DarkGray).fg(Color::White));
762 frame.render_widget(info_widget, chunks[0]);
763
764 let tab_data: Vec<(&str, bool)> = app
766 .tabs
767 .iter()
768 .enumerate()
769 .map(|(i, tab)| (tab.title.as_ref(), i == app.current_tab))
770 .collect();
771 let spans = render_tab_spans(&tab_data);
772
773 let tabs_widget = Paragraph::new(Line::from(spans));
774 frame.render_widget(tabs_widget, chunks[1]);
775}
776
777fn render_top_bar(frame: &mut Frame, app: &App, area: Rect) {
778 let breadcrumbs_str = app.breadcrumbs();
779
780 let breadcrumb_line = if app.current_service == Service::S3Buckets
782 && app.s3_state.current_bucket.is_some()
783 && !app.s3_state.prefix_stack.is_empty()
784 {
785 let parts: Vec<&str> = breadcrumbs_str.split(" > ").collect();
786 let mut spans = Vec::new();
787 for (i, part) in parts.iter().enumerate() {
788 if i > 0 {
789 spans.push(Span::raw(" > "));
790 }
791 if i == parts.len() - 1 {
792 spans.push(Span::styled(
794 *part,
795 Style::default()
796 .fg(Color::Cyan)
797 .add_modifier(Modifier::BOLD),
798 ));
799 } else {
800 spans.push(Span::raw(*part));
801 }
802 }
803 Line::from(spans)
804 } else {
805 Line::from(breadcrumbs_str)
806 };
807
808 let breadcrumb_widget =
809 Paragraph::new(breadcrumb_line).style(Style::default().fg(Color::White));
810
811 frame.render_widget(breadcrumb_widget, area);
812}
813fn render_bottom_bar(frame: &mut Frame, app: &App, area: Rect) {
814 status::render_bottom_bar(frame, app, area);
815}
816
817fn render_service(frame: &mut Frame, app: &App, area: Rect) {
818 match app.current_service {
819 Service::CloudWatchLogGroups => {
820 if app.view_mode == ViewMode::Events {
821 cw::logs::render_events(frame, app, area);
822 } else if app.view_mode == ViewMode::Detail {
823 cw::logs::render_group_detail(frame, app, area);
824 } else {
825 cw::logs::render_groups_list(frame, app, area);
826 }
827 }
828 Service::CloudWatchInsights => cw::render_insights(frame, app, area),
829 Service::CloudWatchAlarms => cw::render_alarms(frame, app, area),
830 Service::Ec2Instances => {
831 if app.ec2_state.current_instance.is_some() {
832 ec2::render_instance_detail(frame, area, app);
833 } else {
834 ec2::render_instances(
835 frame,
836 area,
837 &app.ec2_state,
838 &app.ec2_visible_column_ids
839 .iter()
840 .map(|s| s.as_ref())
841 .collect::<Vec<_>>(),
842 app.mode,
843 );
844 }
845 }
846 Service::EcrRepositories => ecr::render_repositories(frame, app, area),
847 Service::LambdaFunctions => lambda::render_functions(frame, app, area),
848 Service::LambdaApplications => lambda::render_applications(frame, app, area),
849 Service::S3Buckets => s3::render_buckets(frame, app, area),
850 Service::SqsQueues => sqs::render_queues(frame, app, area),
851 Service::CloudFormationStacks => cfn::render_stacks(frame, app, area),
852 Service::IamUsers => iam::render_users(frame, app, area),
853 Service::IamRoles => iam::render_roles(frame, app, area),
854 Service::IamUserGroups => iam::render_user_groups(frame, app, area),
855 }
856}
857
858fn render_column_selector(frame: &mut Frame, app: &App, area: Rect) {
859 let (items, title, max_text_len) = if app.current_service == Service::S3Buckets
860 && app.s3_state.current_bucket.is_none()
861 {
862 let mut all_items: Vec<ListItem> = Vec::new();
863 let mut max_len = 0;
864
865 let (header, header_len) = render_section_header("Columns");
866 all_items.push(header);
867 max_len = max_len.max(header_len);
868
869 for col_id in &app.s3_bucket_column_ids {
870 if let Some(col) = BucketColumn::from_id(col_id) {
871 let is_visible = app.s3_bucket_visible_column_ids.contains(col_id);
872 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
873 all_items.push(item);
874 max_len = max_len.max(len);
875 }
876 }
877
878 all_items.push(ListItem::new(""));
879 let (page_items, page_len) =
880 render_page_size_section(app.s3_state.buckets.page_size, PAGE_SIZE_OPTIONS);
881 all_items.extend(page_items);
882 max_len = max_len.max(page_len);
883
884 (all_items, " Preferences ", max_len)
885 } else if app.current_service == Service::CloudWatchAlarms {
886 let mut all_items: Vec<ListItem> = Vec::new();
887 let mut max_len = 0;
888
889 let (header, header_len) = render_section_header("Columns");
891 all_items.push(header);
892 max_len = max_len.max(header_len);
893
894 for col_id in &app.cw_alarm_column_ids {
895 let is_visible = app.cw_alarm_visible_column_ids.contains(col_id);
896 if let Some(col) = AlarmColumn::from_id(col_id) {
897 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
898 all_items.push(item);
899 max_len = max_len.max(len);
900 }
901 }
902
903 all_items.push(ListItem::new(""));
905 let (header, header_len) = render_section_header("View as");
906 all_items.push(header);
907 max_len = max_len.max(header_len);
908
909 let (item, len) = render_radio_item(
910 "Table",
911 app.alarms_state.view_as == AlarmViewMode::Table,
912 true,
913 );
914 all_items.push(item);
915 max_len = max_len.max(len);
916
917 let (item, len) = render_radio_item(
918 "Cards",
919 app.alarms_state.view_as == AlarmViewMode::Cards,
920 true,
921 );
922 all_items.push(item);
923 max_len = max_len.max(len);
924
925 all_items.push(ListItem::new(""));
927 let (page_items, page_len) =
928 render_page_size_section(app.alarms_state.table.page_size, PAGE_SIZE_OPTIONS);
929 all_items.extend(page_items);
930 max_len = max_len.max(page_len);
931
932 all_items.push(ListItem::new(""));
934 let (header, header_len) = render_section_header("Wrap lines");
935 all_items.push(header);
936 max_len = max_len.max(header_len);
937
938 let (item, len) = render_column_toggle_string("Wrap lines", app.alarms_state.wrap_lines);
939 all_items.push(item);
940 max_len = max_len.max(len);
941
942 (all_items, " Preferences ", max_len)
943 } else if app.view_mode == ViewMode::Events
944 && app.current_service == Service::CloudWatchLogGroups
945 {
946 let mut max_len = 0;
947 let items: Vec<ListItem> = app
948 .cw_log_event_column_ids
949 .iter()
950 .filter_map(|col_id| {
951 EventColumn::from_id(col_id).map(|col| {
952 let is_visible = app.cw_log_event_visible_column_ids.contains(col_id);
953 let (item, len) = render_column_toggle_string(col.name(), is_visible);
954 max_len = max_len.max(len);
955 item
956 })
957 })
958 .collect();
959 (items, " Select visible columns (Space to toggle) ", max_len)
960 } else if app.view_mode == ViewMode::Detail
961 && app.current_service == Service::CloudWatchLogGroups
962 {
963 let mut all_items: Vec<ListItem> = Vec::new();
964 let mut max_len = 0;
965
966 let (header, header_len) = render_section_header("Columns");
967 all_items.push(header);
968 max_len = max_len.max(header_len);
969
970 for col_id in &app.cw_log_stream_column_ids {
971 if let Some(col) = StreamColumn::from_id(col_id) {
972 let is_visible = app.cw_log_stream_visible_column_ids.contains(col_id);
973 let (item, len) = render_column_toggle_string(col.name(), is_visible);
974 all_items.push(item);
975 max_len = max_len.max(len);
976 }
977 }
978
979 all_items.push(ListItem::new(""));
980 let page_size_enum = match app.log_groups_state.stream_page_size {
981 10 => PageSize::Ten,
982 25 => PageSize::TwentyFive,
983 50 => PageSize::Fifty,
984 _ => PageSize::OneHundred,
985 };
986 let (page_items, page_len) = render_page_size_section(page_size_enum, PAGE_SIZE_OPTIONS);
987 all_items.extend(page_items);
988 max_len = max_len.max(page_len);
989
990 (all_items, " Preferences ", max_len)
991 } else if app.current_service == Service::CloudWatchLogGroups {
992 let mut all_items: Vec<ListItem> = Vec::new();
993 let mut max_len = 0;
994
995 let (header, header_len) = render_section_header("Columns");
996 all_items.push(header);
997 max_len = max_len.max(header_len);
998
999 for col_id in &app.cw_log_group_column_ids {
1000 if let Some(col) = LogGroupColumn::from_id(col_id) {
1001 let is_visible = app.cw_log_group_visible_column_ids.contains(col_id);
1002 let (item, len) = render_column_toggle_string(col.name(), is_visible);
1003 all_items.push(item);
1004 max_len = max_len.max(len);
1005 }
1006 }
1007
1008 all_items.push(ListItem::new(""));
1009 let (page_items, page_len) =
1010 render_page_size_section(app.log_groups_state.log_groups.page_size, PAGE_SIZE_OPTIONS);
1011 all_items.extend(page_items);
1012 max_len = max_len.max(page_len);
1013
1014 (all_items, " Preferences ", max_len)
1015 } else if app.current_service == Service::EcrRepositories {
1016 let mut all_items: Vec<ListItem> = Vec::new();
1017 let mut max_len = 0;
1018
1019 let (header, header_len) = render_section_header("Columns");
1020 all_items.push(header);
1021 max_len = max_len.max(header_len);
1022
1023 if app.ecr_state.current_repository.is_some() {
1024 for col_id in &app.ecr_image_column_ids {
1026 if let Some(col) = image::Column::from_id(col_id) {
1027 let is_visible = app.ecr_image_visible_column_ids.contains(col_id);
1028 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1029 all_items.push(item);
1030 max_len = max_len.max(len);
1031 }
1032 }
1033
1034 all_items.push(ListItem::new(""));
1035 let (page_items, page_len) =
1036 render_page_size_section(app.ecr_state.images.page_size, PAGE_SIZE_OPTIONS);
1037 all_items.extend(page_items);
1038 max_len = max_len.max(page_len);
1039 } else {
1040 for col_id in &app.ecr_repo_column_ids {
1042 if let Some(col) = repo::Column::from_id(col_id) {
1043 let is_visible = app.ecr_repo_visible_column_ids.contains(col_id);
1044 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1045 all_items.push(item);
1046 max_len = max_len.max(len);
1047 }
1048 }
1049
1050 all_items.push(ListItem::new(""));
1051 let (page_items, page_len) =
1052 render_page_size_section(app.ecr_state.repositories.page_size, PAGE_SIZE_OPTIONS);
1053 all_items.extend(page_items);
1054 max_len = max_len.max(page_len);
1055 }
1056
1057 (all_items, " Preferences ", max_len)
1058 } else if app.current_service == Service::Ec2Instances {
1059 if app.ec2_state.current_instance.is_some()
1060 && app.ec2_state.detail_tab == ec2::DetailTab::Tags
1061 {
1062 let mut all_items: Vec<ListItem> = Vec::new();
1063 let mut max_len = 0;
1064
1065 let (header, header_len) = render_section_header("Columns");
1066 all_items.push(header);
1067 max_len = max_len.max(header_len);
1068
1069 for col_id in &app.ec2_state.tag_column_ids {
1070 use crate::ec2::tag::Column as TagColumn;
1071 if let Some(col) = TagColumn::from_id(col_id) {
1072 let is_visible = app.ec2_state.tag_visible_column_ids.contains(col_id);
1073 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1074 all_items.push(item);
1075 max_len = max_len.max(len);
1076 }
1077 }
1078
1079 all_items.push(ListItem::new(""));
1080 let (page_items, page_len) =
1081 render_page_size_section(app.ec2_state.tags.page_size, PAGE_SIZE_OPTIONS);
1082 all_items.extend(page_items);
1083 max_len = max_len.max(page_len);
1084
1085 (all_items, " Preferences ", max_len)
1086 } else {
1087 let mut all_items: Vec<ListItem> = Vec::new();
1088 let mut max_len = 0;
1089
1090 let (header, header_len) = render_section_header("Columns");
1091 all_items.push(header);
1092 max_len = max_len.max(header_len);
1093
1094 for col_id in &app.ec2_column_ids {
1095 if let Some(col) = Ec2Column::from_id(col_id) {
1096 let is_visible = app.ec2_visible_column_ids.contains(col_id);
1097 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1098 all_items.push(item);
1099 max_len = max_len.max(len);
1100 }
1101 }
1102
1103 all_items.push(ListItem::new(""));
1104
1105 let (page_items, page_len) =
1106 render_page_size_section(app.ec2_state.table.page_size, PAGE_SIZE_OPTIONS);
1107 all_items.extend(page_items);
1108 max_len = max_len.max(page_len);
1109
1110 (all_items, " Preferences ", max_len)
1111 }
1112 } else if app.current_service == Service::SqsQueues {
1113 if app.sqs_state.current_queue.is_some()
1114 && app.sqs_state.detail_tab == SqsQueueDetailTab::LambdaTriggers
1115 {
1116 let mut all_items: Vec<ListItem> = Vec::new();
1118 let mut max_len = 0;
1119
1120 let (header, header_len) = render_section_header("Columns");
1121 all_items.push(header);
1122 max_len = max_len.max(header_len);
1123
1124 for col_id in &app.sqs_state.trigger_column_ids {
1125 if let Some(col) = SqsTriggerColumn::from_id(col_id) {
1126 let is_visible = app.sqs_state.trigger_visible_column_ids.contains(col_id);
1127 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1128 all_items.push(item);
1129 max_len = max_len.max(len);
1130 }
1131 }
1132
1133 all_items.push(ListItem::new(""));
1134 let (page_items, page_len) =
1135 render_page_size_section(app.sqs_state.triggers.page_size, PAGE_SIZE_OPTIONS);
1136 all_items.extend(page_items);
1137 max_len = max_len.max(page_len);
1138
1139 (all_items, " Preferences ", max_len)
1140 } else if app.sqs_state.current_queue.is_some()
1141 && app.sqs_state.detail_tab == SqsQueueDetailTab::SnsSubscriptions
1142 {
1143 let mut all_items: Vec<ListItem> = Vec::new();
1145 let mut max_len = 0;
1146
1147 let (header, header_len) = render_section_header("Columns");
1148 all_items.push(header);
1149 max_len = max_len.max(header_len);
1150
1151 for col_id in &app.sqs_state.subscription_column_ids {
1152 if let Some(col) = SqsSubscriptionColumn::from_id(col_id) {
1153 let is_visible = app
1154 .sqs_state
1155 .subscription_visible_column_ids
1156 .contains(col_id);
1157 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1158 all_items.push(item);
1159 max_len = max_len.max(len);
1160 }
1161 }
1162
1163 all_items.push(ListItem::new(""));
1164 let (page_items, page_len) =
1165 render_page_size_section(app.sqs_state.subscriptions.page_size, PAGE_SIZE_OPTIONS);
1166 all_items.extend(page_items);
1167 max_len = max_len.max(page_len);
1168
1169 (all_items, " Preferences ", max_len)
1170 } else if app.sqs_state.current_queue.is_some()
1171 && app.sqs_state.detail_tab == SqsQueueDetailTab::EventBridgePipes
1172 {
1173 let mut all_items: Vec<ListItem> = Vec::new();
1175 let mut max_len = 0;
1176
1177 let (header, header_len) = render_section_header("Columns");
1178 all_items.push(header);
1179 max_len = max_len.max(header_len);
1180
1181 for col_id in &app.sqs_state.pipe_column_ids {
1182 if let Some(col) = SqsPipeColumn::from_id(col_id) {
1183 let is_visible = app.sqs_state.pipe_visible_column_ids.contains(col_id);
1184 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1185 all_items.push(item);
1186 max_len = max_len.max(len);
1187 }
1188 }
1189
1190 all_items.push(ListItem::new(""));
1191 let (page_items, page_len) =
1192 render_page_size_section(app.sqs_state.pipes.page_size, PAGE_SIZE_OPTIONS);
1193 all_items.extend(page_items);
1194 max_len = max_len.max(page_len);
1195
1196 (all_items, " Preferences ", max_len)
1197 } else if app.sqs_state.current_queue.is_some()
1198 && app.sqs_state.detail_tab == SqsQueueDetailTab::Tagging
1199 {
1200 let mut all_items: Vec<ListItem> = Vec::new();
1202 let mut max_len = 0;
1203
1204 let (header, header_len) = render_section_header("Columns");
1205 all_items.push(header);
1206 max_len = max_len.max(header_len);
1207
1208 for col_id in &app.sqs_state.tag_column_ids {
1209 if let Some(col) = SqsTagColumn::from_id(col_id) {
1210 let is_visible = app.sqs_state.tag_visible_column_ids.contains(col_id);
1211 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1212 all_items.push(item);
1213 max_len = max_len.max(len);
1214 }
1215 }
1216
1217 all_items.push(ListItem::new(""));
1218 let (page_items, page_len) =
1219 render_page_size_section(app.sqs_state.tags.page_size, PAGE_SIZE_OPTIONS);
1220 all_items.extend(page_items);
1221 max_len = max_len.max(page_len);
1222
1223 (all_items, " Preferences ", max_len)
1224 } else if app.sqs_state.current_queue.is_none() {
1225 let mut all_items: Vec<ListItem> = Vec::new();
1227 let mut max_len = 0;
1228
1229 let (header, header_len) = render_section_header("Columns");
1230 all_items.push(header);
1231 max_len = max_len.max(header_len);
1232
1233 for col_id in &app.sqs_column_ids {
1234 if let Some(col) = SqsColumn::from_id(col_id) {
1235 let is_visible = app.sqs_visible_column_ids.contains(col_id);
1236 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1237 all_items.push(item);
1238 max_len = max_len.max(len);
1239 }
1240 }
1241
1242 all_items.push(ListItem::new(""));
1243 let (page_items, page_len) =
1244 render_page_size_section(app.sqs_state.queues.page_size, PAGE_SIZE_OPTIONS);
1245 all_items.extend(page_items);
1246 max_len = max_len.max(page_len);
1247
1248 (all_items, " Preferences ", max_len)
1249 } else {
1250 (vec![], " Preferences ", 0)
1251 }
1252 } else if app.current_service == Service::LambdaFunctions {
1253 let mut all_items: Vec<ListItem> = Vec::new();
1254 let mut max_len = 0;
1255
1256 let (header, header_len) = render_section_header("Columns");
1257 all_items.push(header);
1258 max_len = max_len.max(header_len);
1259
1260 if app.lambda_state.current_function.is_some()
1262 && app.lambda_state.detail_tab == LambdaDetailTab::Code
1263 {
1264 for col in &app.lambda_state.layer_column_ids {
1266 let is_visible = app.lambda_state.layer_visible_column_ids.contains(col);
1267 let (item, len) = render_column_toggle_string(col, is_visible);
1268 all_items.push(item);
1269 max_len = max_len.max(len);
1270 }
1271 } else if app.lambda_state.detail_tab == LambdaDetailTab::Versions {
1272 for col in &app.lambda_state.version_column_ids {
1273 let is_visible = app.lambda_state.version_visible_column_ids.contains(col);
1274 let (item, len) = render_column_toggle_string(col, is_visible);
1275 all_items.push(item);
1276 max_len = max_len.max(len);
1277 }
1278 } else if app.lambda_state.detail_tab == LambdaDetailTab::Aliases {
1279 for col in &app.lambda_state.alias_column_ids {
1280 let is_visible = app.lambda_state.alias_visible_column_ids.contains(col);
1281 let (item, len) = render_column_toggle_string(col, is_visible);
1282 all_items.push(item);
1283 max_len = max_len.max(len);
1284 }
1285 } else {
1286 for col_id in &app.lambda_state.function_column_ids {
1287 if let Some(col) = FunctionColumn::from_id(col_id) {
1288 let is_visible = app
1289 .lambda_state
1290 .function_visible_column_ids
1291 .contains(col_id);
1292 let (item, len) = render_column_toggle_string(col.name(), is_visible);
1293 all_items.push(item);
1294 max_len = max_len.max(len);
1295 }
1296 }
1297 }
1298
1299 all_items.push(ListItem::new(""));
1300
1301 let (page_items, page_len) = render_page_size_section(
1302 if app.lambda_state.detail_tab == LambdaDetailTab::Versions {
1303 app.lambda_state.version_table.page_size
1304 } else {
1305 app.lambda_state.table.page_size
1306 },
1307 PAGE_SIZE_OPTIONS,
1308 );
1309 all_items.extend(page_items);
1310 max_len = max_len.max(page_len);
1311
1312 (all_items, " Preferences ", max_len)
1313 } else if app.current_service == Service::LambdaApplications {
1314 let mut all_items: Vec<ListItem> = Vec::new();
1315 let mut max_len = 0;
1316
1317 let (header, header_len) = render_section_header("Columns");
1318 all_items.push(header);
1319 max_len = max_len.max(header_len);
1320
1321 if app.lambda_application_state.current_application.is_some() {
1323 if app.lambda_application_state.detail_tab == ApplicationDetailTab::Overview {
1324 for col_id in &app.lambda_resource_column_ids {
1326 let is_visible = app.lambda_resource_visible_column_ids.contains(col_id);
1327 if let Some(col) = ResourceColumn::from_id(col_id) {
1328 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1329 all_items.push(item);
1330 max_len = max_len.max(len);
1331 }
1332 }
1333
1334 all_items.push(ListItem::new(""));
1335 let (page_items, page_len) = render_page_size_section(
1336 app.lambda_application_state.resources.page_size,
1337 PAGE_SIZE_OPTIONS_SMALL,
1338 );
1339 all_items.extend(page_items);
1340 max_len = max_len.max(page_len);
1341 } else {
1342 for col_id in &app.lambda_deployment_column_ids {
1344 let is_visible = app.lambda_deployment_visible_column_ids.contains(col_id);
1345 if let Some(col) = DeploymentColumn::from_id(col_id) {
1346 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1347 all_items.push(item);
1348 max_len = max_len.max(len);
1349 }
1350 }
1351
1352 all_items.push(ListItem::new(""));
1353 let (page_items, page_len) = render_page_size_section(
1354 app.lambda_application_state.deployments.page_size,
1355 PAGE_SIZE_OPTIONS_SMALL,
1356 );
1357 all_items.extend(page_items);
1358 max_len = max_len.max(page_len);
1359 }
1360 } else {
1361 for col_id in &app.lambda_application_column_ids {
1363 if let Some(col) = ApplicationColumn::from_id(col_id) {
1364 let is_visible = app.lambda_application_visible_column_ids.contains(col_id);
1365 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1366 all_items.push(item);
1367 max_len = max_len.max(len);
1368 }
1369 }
1370
1371 all_items.push(ListItem::new(""));
1372 let (page_items, page_len) = render_page_size_section(
1373 app.lambda_application_state.table.page_size,
1374 PAGE_SIZE_OPTIONS_SMALL,
1375 );
1376 all_items.extend(page_items);
1377 max_len = max_len.max(page_len);
1378 }
1379
1380 (all_items, " Preferences ", max_len)
1381 } else if app.current_service == Service::CloudFormationStacks {
1382 let mut all_items: Vec<ListItem> = Vec::new();
1383 let mut max_len = 0;
1384
1385 if app.cfn_state.current_stack.is_some()
1387 && app.cfn_state.detail_tab == CfnDetailTab::StackInfo
1388 {
1389 let (header, header_len) = render_section_header("Columns");
1390 all_items.push(header);
1391 max_len = max_len.max(header_len);
1392
1393 let tag_columns = ["Key", "Value"];
1395 for col_name in &tag_columns {
1396 let (item, len) = render_column_toggle_string(col_name, true);
1397 all_items.push(item);
1398 max_len = max_len.max(len);
1399 }
1400
1401 all_items.push(ListItem::new(""));
1402 let (page_items, page_len) =
1403 render_page_size_section(app.cfn_state.tags.page_size, PAGE_SIZE_OPTIONS);
1404 all_items.extend(page_items);
1405 max_len = max_len.max(page_len);
1406 } else if app.cfn_state.current_stack.is_some()
1407 && app.cfn_state.detail_tab == CfnDetailTab::Parameters
1408 {
1409 let (header, header_len) = render_section_header("Columns");
1410 all_items.push(header);
1411 max_len = max_len.max(header_len);
1412
1413 for col_id in &app.cfn_parameter_column_ids {
1414 let is_visible = app.cfn_parameter_visible_column_ids.contains(col_id);
1415 if let Some(col) = ParameterColumn::from_id(col_id) {
1416 let name = translate_column(col.id(), col.default_name());
1417 let (item, len) = render_column_toggle_string(&name, is_visible);
1418 all_items.push(item);
1419 max_len = max_len.max(len);
1420 }
1421 }
1422
1423 all_items.push(ListItem::new(""));
1424 let (page_items, page_len) =
1425 render_page_size_section(app.cfn_state.parameters.page_size, PAGE_SIZE_OPTIONS);
1426 all_items.extend(page_items);
1427 max_len = max_len.max(page_len);
1428 } else if app.cfn_state.current_stack.is_some()
1429 && app.cfn_state.detail_tab == CfnDetailTab::Outputs
1430 {
1431 let (header, header_len) = render_section_header("Columns");
1432 all_items.push(header);
1433 max_len = max_len.max(header_len);
1434
1435 for col_id in &app.cfn_output_column_ids {
1436 let is_visible = app.cfn_output_visible_column_ids.contains(col_id);
1437 if let Some(col) = OutputColumn::from_id(col_id) {
1438 let name = translate_column(col.id(), col.default_name());
1439 let (item, len) = render_column_toggle_string(&name, is_visible);
1440 all_items.push(item);
1441 max_len = max_len.max(len);
1442 }
1443 }
1444
1445 all_items.push(ListItem::new(""));
1446 let (page_items, page_len) =
1447 render_page_size_section(app.cfn_state.outputs.page_size, PAGE_SIZE_OPTIONS);
1448 all_items.extend(page_items);
1449 max_len = max_len.max(page_len);
1450 } else if app.cfn_state.current_stack.is_some()
1451 && app.cfn_state.detail_tab == CfnDetailTab::Resources
1452 {
1453 let (header, header_len) = render_section_header("Columns");
1454 all_items.push(header);
1455 max_len = max_len.max(header_len);
1456
1457 for col_id in &app.cfn_resource_column_ids {
1458 let is_visible = app.cfn_resource_visible_column_ids.contains(col_id);
1459 if let Some(col) = CfnResourceColumn::from_id(col_id) {
1460 let name = translate_column(col.id(), col.default_name());
1461 let (item, len) = render_column_toggle_string(&name, is_visible);
1462 all_items.push(item);
1463 max_len = max_len.max(len);
1464 }
1465 }
1466
1467 all_items.push(ListItem::new(""));
1468 let (page_items, page_len) =
1469 render_page_size_section(app.cfn_state.resources.page_size, PAGE_SIZE_OPTIONS);
1470 all_items.extend(page_items);
1471 max_len = max_len.max(page_len);
1472 } else if app.cfn_state.current_stack.is_none() {
1473 let (header, header_len) = render_section_header("Columns");
1475 all_items.push(header);
1476 max_len = max_len.max(header_len);
1477
1478 for col_id in &app.cfn_column_ids {
1479 let is_visible = app.cfn_visible_column_ids.contains(col_id);
1480 if let Some(col) = CfnColumn::from_id(col_id) {
1481 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1482 all_items.push(item);
1483 max_len = max_len.max(len);
1484 }
1485 }
1486
1487 all_items.push(ListItem::new(""));
1488 let (page_items, page_len) =
1489 render_page_size_section(app.cfn_state.table.page_size, PAGE_SIZE_OPTIONS);
1490 all_items.extend(page_items);
1491 max_len = max_len.max(page_len);
1492 }
1493 (all_items, " Preferences ", max_len)
1496 } else if app.current_service == Service::IamUsers {
1497 let mut all_items: Vec<ListItem> = Vec::new();
1498 let mut max_len = 0;
1499
1500 if app.iam_state.current_user.is_some() {
1502 match app.iam_state.user_tab {
1503 UserTab::Permissions => {
1504 let (header, header_len) = render_section_header("Columns");
1505 all_items.push(header);
1506 max_len = max_len.max(header_len);
1507
1508 for col in &app.iam_policy_column_ids {
1509 let is_visible = app.iam_policy_visible_column_ids.contains(col);
1510 let mut spans = vec![];
1511 spans.extend(render_toggle(is_visible));
1512 spans.push(Span::raw(" "));
1513 spans.push(Span::raw(col.clone()));
1514 let text_len = 4 + col.len();
1515 all_items.push(ListItem::new(Line::from(spans)));
1516 max_len = max_len.max(text_len);
1517 }
1518
1519 all_items.push(ListItem::new(""));
1520 let (page_items, page_len) = render_page_size_section(
1521 app.iam_state.policies.page_size,
1522 PAGE_SIZE_OPTIONS_SMALL,
1523 );
1524 all_items.extend(page_items);
1525 max_len = max_len.max(page_len);
1526 }
1527 UserTab::Groups => {
1528 let (header, header_len) = render_section_header("Columns");
1529 all_items.push(header);
1530 max_len = max_len.max(header_len);
1531
1532 for col in &["Group name", "Attached policies"] {
1533 let mut spans = vec![];
1534 spans.extend(render_toggle(true));
1535 spans.push(Span::raw(" "));
1536 spans.push(Span::raw(*col));
1537 let text_len = 4 + col.len();
1538 all_items.push(ListItem::new(Line::from(spans)));
1539 max_len = max_len.max(text_len);
1540 }
1541
1542 all_items.push(ListItem::new(""));
1543 let (page_items, page_len) = render_page_size_section(
1544 app.iam_state.user_group_memberships.page_size,
1545 PAGE_SIZE_OPTIONS_SMALL,
1546 );
1547 all_items.extend(page_items);
1548 max_len = max_len.max(page_len);
1549 }
1550 UserTab::Tags => {
1551 let (header, header_len) = render_section_header("Columns");
1552 all_items.push(header);
1553 max_len = max_len.max(header_len);
1554
1555 for col in &["Key", "Value"] {
1556 let mut spans = vec![];
1557 spans.extend(render_toggle(true));
1558 spans.push(Span::raw(" "));
1559 spans.push(Span::raw(*col));
1560 let text_len = 4 + col.len();
1561 all_items.push(ListItem::new(Line::from(spans)));
1562 max_len = max_len.max(text_len);
1563 }
1564
1565 all_items.push(ListItem::new(""));
1566 let (page_items, page_len) = render_page_size_section(
1567 app.iam_state.user_tags.page_size,
1568 PAGE_SIZE_OPTIONS_SMALL,
1569 );
1570 all_items.extend(page_items);
1571 max_len = max_len.max(page_len);
1572 }
1573 UserTab::LastAccessed => {
1574 let (header, header_len) = render_section_header("Columns");
1575 all_items.push(header);
1576 max_len = max_len.max(header_len);
1577
1578 for col in &["Service", "Policies granting", "Last accessed"] {
1579 let mut spans = vec![];
1580 spans.extend(render_toggle(true));
1581 spans.push(Span::raw(" "));
1582 spans.push(Span::raw(*col));
1583 let text_len = 4 + col.len();
1584 all_items.push(ListItem::new(Line::from(spans)));
1585 max_len = max_len.max(text_len);
1586 }
1587
1588 all_items.push(ListItem::new(""));
1589 let (page_items, page_len) = render_page_size_section(
1590 app.iam_state.last_accessed_services.page_size,
1591 PAGE_SIZE_OPTIONS_SMALL,
1592 );
1593 all_items.extend(page_items);
1594 max_len = max_len.max(page_len);
1595 }
1596 _ => {}
1597 }
1598 } else if app.iam_state.current_user.is_none() {
1599 let (header, header_len) = render_section_header("Columns");
1600 all_items.push(header);
1601 max_len = max_len.max(header_len);
1602
1603 for col_id in &app.iam_user_column_ids {
1604 if let Some(col) = UserColumn::from_id(col_id) {
1605 let is_visible = app.iam_user_visible_column_ids.contains(col_id);
1606 let (item, len) = render_column_toggle_string(col.default_name(), is_visible);
1607 all_items.push(item);
1608 max_len = max_len.max(len);
1609 }
1610 }
1611
1612 all_items.push(ListItem::new(""));
1613 let (page_items, page_len) =
1614 render_page_size_section(app.iam_state.users.page_size, PAGE_SIZE_OPTIONS_SMALL);
1615 all_items.extend(page_items);
1616 max_len = max_len.max(page_len);
1617 }
1618
1619 (all_items, " Preferences ", max_len)
1620 } else if app.current_service == Service::IamRoles {
1621 let mut all_items: Vec<ListItem> = Vec::new();
1622 let mut max_len = 0;
1623
1624 if app.iam_state.current_role.is_some() {
1626 match app.iam_state.role_tab {
1627 RoleTab::Permissions => {
1628 let (header, header_len) = render_section_header("Columns");
1630 all_items.push(header);
1631 max_len = max_len.max(header_len);
1632
1633 for col in &app.iam_policy_column_ids {
1634 let is_visible = app.iam_policy_visible_column_ids.contains(col);
1635 let mut spans = vec![];
1636 spans.extend(render_toggle(is_visible));
1637 spans.push(Span::raw(" "));
1638 spans.push(Span::raw(col.clone()));
1639 let text_len = 4 + col.len();
1640 all_items.push(ListItem::new(Line::from(spans)));
1641 max_len = max_len.max(text_len);
1642 }
1643
1644 all_items.push(ListItem::new(""));
1645 let (page_items, page_len) = render_page_size_section(
1646 app.iam_state.policies.page_size,
1647 PAGE_SIZE_OPTIONS_SMALL,
1648 );
1649 all_items.extend(page_items);
1650 max_len = max_len.max(page_len);
1651 }
1652 RoleTab::Tags => {
1653 let (header, header_len) = render_section_header("Columns");
1655 all_items.push(header);
1656 max_len = max_len.max(header_len);
1657
1658 for col in &["Key", "Value"] {
1659 let mut spans = vec![];
1660 spans.extend(render_toggle(true)); spans.push(Span::raw(" "));
1662 spans.push(Span::raw(*col));
1663 let text_len = 4 + col.len();
1664 all_items.push(ListItem::new(Line::from(spans)));
1665 max_len = max_len.max(text_len);
1666 }
1667
1668 all_items.push(ListItem::new(""));
1669 let (page_items, page_len) = render_page_size_section(
1670 app.iam_state.tags.page_size,
1671 PAGE_SIZE_OPTIONS_SMALL,
1672 );
1673 all_items.extend(page_items);
1674 max_len = max_len.max(page_len);
1675 }
1676 RoleTab::LastAccessed => {
1677 let (header, header_len) = render_section_header("Columns");
1678 all_items.push(header);
1679 max_len = max_len.max(header_len);
1680
1681 for col in &["Service", "Policies granting", "Last accessed"] {
1682 let mut spans = vec![];
1683 spans.extend(render_toggle(true));
1684 spans.push(Span::raw(" "));
1685 spans.push(Span::raw(*col));
1686 let text_len = 4 + col.len();
1687 all_items.push(ListItem::new(Line::from(spans)));
1688 max_len = max_len.max(text_len);
1689 }
1690
1691 all_items.push(ListItem::new(""));
1692 let (page_items, page_len) = render_page_size_section(
1693 app.iam_state.last_accessed_services.page_size,
1694 PAGE_SIZE_OPTIONS_SMALL,
1695 );
1696 all_items.extend(page_items);
1697 max_len = max_len.max(page_len);
1698 }
1699 _ => {
1700 }
1702 }
1703 } else {
1704 let (header, header_len) = render_section_header("Columns");
1706 all_items.push(header);
1707 max_len = max_len.max(header_len);
1708
1709 for col_id in &app.iam_role_column_ids {
1710 if let Some(col) = RoleColumn::from_id(col_id) {
1711 let is_visible = app.iam_role_visible_column_ids.contains(col_id);
1712 let (item, len) = render_column_toggle_string(col.default_name(), is_visible);
1713 all_items.push(item);
1714 max_len = max_len.max(len);
1715 }
1716 }
1717
1718 all_items.push(ListItem::new(""));
1719 let (page_items, page_len) =
1720 render_page_size_section(app.iam_state.roles.page_size, PAGE_SIZE_OPTIONS_SMALL);
1721 all_items.extend(page_items);
1722 max_len = max_len.max(page_len);
1723 }
1724
1725 (all_items, " Preferences ", max_len)
1726 } else if app.current_service == Service::IamUserGroups {
1727 let mut all_items: Vec<ListItem> = Vec::new();
1728 let mut max_len = 0;
1729
1730 let (header, header_len) = render_section_header("Columns");
1731 all_items.push(header);
1732 max_len = max_len.max(header_len);
1733
1734 for col in &app.iam_group_column_ids {
1735 let is_visible = app.iam_group_visible_column_ids.contains(col);
1736 let mut spans = vec![];
1737 spans.extend(render_toggle(is_visible));
1738 spans.push(Span::raw(" "));
1739 spans.push(Span::raw(col.clone()));
1740 let text_len = 4 + col.len();
1741 all_items.push(ListItem::new(Line::from(spans)));
1742 max_len = max_len.max(text_len);
1743 }
1744
1745 all_items.push(ListItem::new(""));
1746 let (page_items, page_len) =
1747 render_page_size_section(app.iam_state.groups.page_size, PAGE_SIZE_OPTIONS_SMALL);
1748 all_items.extend(page_items);
1749 max_len = max_len.max(page_len);
1750
1751 (all_items, " Preferences ", max_len)
1752 } else {
1753 (vec![], " Preferences ", 0)
1755 };
1756
1757 let item_count = items.len();
1759
1760 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);
1766 let actual_height = height.min(max_height);
1767 let popup_area = centered_rect_absolute(width, actual_height, area);
1768
1769 let needs_scrollbar = height > max_height;
1771
1772 let border_color = Color::Green;
1774
1775 let list = List::new(items)
1776 .block(
1777 Block::default()
1778 .title(title)
1779 .borders(Borders::ALL)
1780 .border_type(BorderType::Rounded)
1781 .border_type(BorderType::Rounded)
1782 .border_style(Style::default().fg(border_color)),
1783 )
1784 .highlight_style(Style::default().bg(Color::DarkGray))
1785 .highlight_symbol("► ");
1786
1787 let mut state = ListState::default();
1788 state.select(Some(app.column_selector_index));
1789
1790 frame.render_widget(Clear, popup_area);
1791 frame.render_stateful_widget(list, popup_area, &mut state);
1792
1793 if needs_scrollbar {
1795 render_scrollbar(
1796 frame,
1797 popup_area.inner(Margin {
1798 vertical: 1,
1799 horizontal: 0,
1800 }),
1801 item_count,
1802 app.column_selector_index,
1803 );
1804 }
1805}
1806
1807fn render_error_modal(frame: &mut Frame, app: &App, area: Rect) {
1808 let popup_area = centered_rect(80, 60, area);
1809
1810 frame.render_widget(Clear, popup_area);
1811 frame.render_widget(
1812 Block::default()
1813 .title(format_title("Error"))
1814 .borders(Borders::ALL)
1815 .border_type(BorderType::Rounded)
1816 .border_type(BorderType::Rounded)
1817 .border_style(red_text())
1818 .style(Style::default().bg(Color::Black)),
1819 popup_area,
1820 );
1821
1822 let inner = popup_area.inner(Margin {
1823 vertical: 1,
1824 horizontal: 1,
1825 });
1826
1827 let error_text = app.error_message.as_deref().unwrap_or("Unknown error");
1828
1829 let chunks = vertical(
1830 [
1831 Constraint::Length(2), Constraint::Min(0), Constraint::Length(2), ],
1835 inner,
1836 );
1837
1838 let header = Paragraph::new("AWS Error")
1840 .alignment(Alignment::Center)
1841 .style(red_text().add_modifier(Modifier::BOLD));
1842 frame.render_widget(header, chunks[0]);
1843
1844 let error_lines: Vec<Line> = error_text
1846 .lines()
1847 .skip(app.error_scroll)
1848 .flat_map(|line| {
1849 let width = chunks[1].width.saturating_sub(4) as usize; if line.len() <= width {
1851 vec![Line::from(line)]
1852 } else {
1853 line.chars()
1854 .collect::<Vec<_>>()
1855 .chunks(width)
1856 .map(|chunk| Line::from(chunk.iter().collect::<String>()))
1857 .collect()
1858 }
1859 })
1860 .collect();
1861
1862 let error_paragraph = Paragraph::new(error_lines)
1863 .block(
1864 Block::default()
1865 .borders(Borders::ALL)
1866 .border_type(BorderType::Rounded)
1867 .border_type(BorderType::Rounded)
1868 .border_style(active_border()),
1869 )
1870 .style(Style::default().fg(Color::White));
1871
1872 frame.render_widget(error_paragraph, chunks[1]);
1873
1874 let total_lines: usize = error_text
1876 .lines()
1877 .map(|line| {
1878 let width = chunks[1].width.saturating_sub(4) as usize;
1879 if line.len() <= width {
1880 1
1881 } else {
1882 line.len().div_ceil(width)
1883 }
1884 })
1885 .sum();
1886 let visible_lines = chunks[1].height.saturating_sub(2) as usize;
1887 if total_lines > visible_lines {
1888 render_scrollbar(
1889 frame,
1890 chunks[1].inner(Margin {
1891 vertical: 1,
1892 horizontal: 0,
1893 }),
1894 total_lines,
1895 app.error_scroll,
1896 );
1897 }
1898
1899 let help_spans = vec![
1901 first_hint("^r", "retry"),
1902 hint("y", "copy"),
1903 hint("↑↓,^u,^d", "scroll"),
1904 last_hint("q,⎋", "close"),
1905 ]
1906 .into_iter()
1907 .flatten()
1908 .collect::<Vec<_>>();
1909 let help = Paragraph::new(Line::from(help_spans)).alignment(Alignment::Center);
1910
1911 frame.render_widget(help, chunks[2]);
1912}
1913
1914fn render_space_menu(frame: &mut Frame, area: Rect) {
1915 let items = vec![
1916 Line::from(vec![
1917 Span::styled("o", Style::default().fg(Color::Yellow)),
1918 Span::raw(" services"),
1919 ]),
1920 Line::from(vec![
1921 Span::styled("t", Style::default().fg(Color::Yellow)),
1922 Span::raw(" tabs"),
1923 ]),
1924 Line::from(vec![
1925 Span::styled("c", Style::default().fg(Color::Yellow)),
1926 Span::raw(" close"),
1927 ]),
1928 Line::from(vec![
1929 Span::styled("r", Style::default().fg(Color::Yellow)),
1930 Span::raw(" regions"),
1931 ]),
1932 Line::from(vec![
1933 Span::styled("s", Style::default().fg(Color::Yellow)),
1934 Span::raw(" sessions"),
1935 ]),
1936 Line::from(vec![
1937 Span::styled("h", Style::default().fg(Color::Yellow)),
1938 Span::raw(" help"),
1939 ]),
1940 ];
1941
1942 let menu_height = items.len() as u16 + 2; let menu_area = bottom_right_rect(30, menu_height, area);
1944
1945 let paragraph = Paragraph::new(items)
1946 .block(
1947 Block::default()
1948 .title(format_title("Menu"))
1949 .borders(Borders::ALL)
1950 .border_type(BorderType::Rounded)
1951 .border_type(BorderType::Rounded)
1952 .border_type(BorderType::Rounded)
1953 .border_style(Style::default().fg(Color::Cyan)),
1954 )
1955 .style(Style::default().bg(Color::Black));
1956
1957 frame.render_widget(Clear, menu_area);
1958 frame.render_widget(paragraph, menu_area);
1959}
1960
1961fn render_service_picker(frame: &mut Frame, app: &App, area: Rect) {
1962 let popup_area = centered_rect(60, 60, area);
1963
1964 let chunks = Layout::default()
1965 .direction(Direction::Vertical)
1966 .constraints([Constraint::Length(3), Constraint::Min(0)])
1967 .split(popup_area);
1968
1969 let is_active = app.mode == Mode::ServicePicker;
1970 let cursor = get_cursor(is_active);
1971 let active_color = Color::Green;
1972 let inactive_color = Color::Cyan;
1973 let filter = Paragraph::new(Line::from(vec![
1974 Span::raw(&app.service_picker.filter),
1975 Span::styled(cursor, Style::default().fg(active_color)),
1976 ]))
1977 .block(
1978 Block::default()
1979 .title(SEARCH_ICON)
1980 .borders(Borders::ALL)
1981 .border_type(BorderType::Rounded)
1982 .border_type(BorderType::Rounded)
1983 .border_style(Style::default().fg(if is_active {
1984 active_color
1985 } else {
1986 inactive_color
1987 })),
1988 )
1989 .style(Style::default());
1990
1991 let filtered = app.filtered_services();
1992 let items: Vec<ListItem> = filtered.iter().map(|s| ListItem::new(*s)).collect();
1993
1994 let list = List::new(items)
1995 .block(
1996 Block::default()
1997 .title(format_title("AWS Services"))
1998 .borders(Borders::ALL)
1999 .border_type(BorderType::Rounded)
2000 .border_type(BorderType::Rounded)
2001 .border_type(BorderType::Rounded)
2002 .border_style(if is_active {
2003 active_border()
2004 } else {
2005 Style::default().fg(Color::Cyan)
2006 }),
2007 )
2008 .highlight_style(Style::default().bg(Color::DarkGray))
2009 .highlight_symbol("► ");
2010
2011 let mut state = ListState::default();
2012 state.select(Some(app.service_picker.selected));
2013
2014 frame.render_widget(Clear, popup_area);
2015 frame.render_widget(filter, chunks[0]);
2016 frame.render_stateful_widget(list, chunks[1], &mut state);
2017}
2018
2019fn render_tab_picker(frame: &mut Frame, app: &App, area: Rect) {
2020 let popup_area = centered_rect(80, 60, area);
2021
2022 let main_chunks = Layout::default()
2024 .direction(Direction::Vertical)
2025 .constraints([Constraint::Length(3), Constraint::Min(0)])
2026 .split(popup_area);
2027
2028 let filter_text = if app.tab_filter.is_empty() {
2030 "Type to filter tabs...".to_string()
2031 } else {
2032 app.tab_filter.clone()
2033 };
2034 let filter_style = if app.tab_filter.is_empty() {
2035 Style::default().fg(Color::DarkGray)
2036 } else {
2037 Style::default()
2038 };
2039 let filter = Paragraph::new(filter_text).style(filter_style).block(
2040 Block::default()
2041 .title(SEARCH_ICON)
2042 .borders(Borders::ALL)
2043 .border_type(BorderType::Rounded)
2044 .border_type(BorderType::Rounded)
2045 .border_style(Style::default().fg(Color::Yellow)),
2046 );
2047 frame.render_widget(Clear, main_chunks[0]);
2048 frame.render_widget(filter, main_chunks[0]);
2049
2050 let chunks = Layout::default()
2051 .direction(Direction::Horizontal)
2052 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
2053 .split(main_chunks[1]);
2054
2055 let filtered_tabs = app.get_filtered_tabs();
2057 let items: Vec<ListItem> = filtered_tabs
2058 .iter()
2059 .map(|(_, tab)| ListItem::new(tab.breadcrumb.clone()))
2060 .collect();
2061
2062 let list = List::new(items)
2063 .block(
2064 Block::default()
2065 .title(format_title(&format!(
2066 "Tabs ({}/{})",
2067 filtered_tabs.len(),
2068 app.tabs.len()
2069 )))
2070 .borders(Borders::ALL)
2071 .border_type(BorderType::Rounded)
2072 .border_type(BorderType::Rounded)
2073 .border_type(BorderType::Rounded)
2074 .border_style(active_border()),
2075 )
2076 .highlight_style(Style::default().bg(Color::DarkGray))
2077 .highlight_symbol("► ");
2078
2079 let mut state = ListState::default();
2080 state.select(Some(app.tab_picker_selected));
2081
2082 frame.render_widget(Clear, chunks[0]);
2083 frame.render_stateful_widget(list, chunks[0], &mut state);
2084
2085 frame.render_widget(Clear, chunks[1]);
2087
2088 let preview_block = Block::default()
2089 .title(format_title("Preview"))
2090 .borders(Borders::ALL)
2091 .border_type(BorderType::Rounded)
2092 .border_type(BorderType::Rounded)
2093 .border_style(Style::default().fg(Color::Cyan));
2094
2095 let preview_inner = preview_block.inner(chunks[1]);
2096 frame.render_widget(preview_block, chunks[1]);
2097
2098 if let Some(&(_, tab)) = filtered_tabs.get(app.tab_picker_selected) {
2099 render_service_preview(frame, app, tab.service, preview_inner);
2102 }
2103}
2104
2105fn render_service_preview(frame: &mut Frame, app: &App, service: Service, area: Rect) {
2106 match service {
2107 Service::CloudWatchLogGroups => {
2108 if app.view_mode == ViewMode::Events {
2109 cw::logs::render_events(frame, app, area);
2110 } else if app.view_mode == ViewMode::Detail {
2111 cw::logs::render_group_detail(frame, app, area);
2112 } else {
2113 cw::logs::render_groups_list(frame, app, area);
2114 }
2115 }
2116 Service::CloudWatchInsights => cw::render_insights(frame, app, area),
2117 Service::CloudWatchAlarms => cw::render_alarms(frame, app, area),
2118 Service::Ec2Instances => {
2119 if app.ec2_state.current_instance.is_some() {
2120 ec2::render_instance_detail(frame, area, app);
2121 } else {
2122 ec2::render_instances(
2123 frame,
2124 area,
2125 &app.ec2_state,
2126 &app.ec2_visible_column_ids
2127 .iter()
2128 .map(|s| s.as_ref())
2129 .collect::<Vec<_>>(),
2130 app.mode,
2131 );
2132 }
2133 }
2134 Service::EcrRepositories => ecr::render_repositories(frame, app, area),
2135 Service::LambdaFunctions => lambda::render_functions(frame, app, area),
2136 Service::LambdaApplications => lambda::render_applications(frame, app, area),
2137 Service::S3Buckets => s3::render_buckets(frame, app, area),
2138 Service::SqsQueues => sqs::render_queues(frame, app, area),
2139 Service::CloudFormationStacks => cfn::render_stacks(frame, app, area),
2140 Service::IamUsers => iam::render_users(frame, app, area),
2141 Service::IamRoles => iam::render_roles(frame, app, area),
2142 Service::IamUserGroups => iam::render_user_groups(frame, app, area),
2143 }
2144}
2145
2146fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
2147 let popup_layout = Layout::default()
2148 .direction(Direction::Vertical)
2149 .constraints([
2150 Constraint::Percentage((100 - percent_y) / 2),
2151 Constraint::Percentage(percent_y),
2152 Constraint::Percentage((100 - percent_y) / 2),
2153 ])
2154 .split(r);
2155
2156 Layout::default()
2157 .direction(Direction::Horizontal)
2158 .constraints([
2159 Constraint::Percentage((100 - percent_x) / 2),
2160 Constraint::Percentage(percent_x),
2161 Constraint::Percentage((100 - percent_x) / 2),
2162 ])
2163 .split(popup_layout[1])[1]
2164}
2165
2166fn centered_rect_absolute(width: u16, height: u16, r: Rect) -> Rect {
2167 let x = (r.width.saturating_sub(width)) / 2;
2168 let y = (r.height.saturating_sub(height)) / 2;
2169 Rect {
2170 x: r.x + x,
2171 y: r.y + y,
2172 width: width.min(r.width),
2173 height: height.min(r.height),
2174 }
2175}
2176
2177fn bottom_right_rect(width: u16, height: u16, r: Rect) -> Rect {
2178 let x = r.width.saturating_sub(width + 1);
2179 let y = r.height.saturating_sub(height + 1);
2180 Rect {
2181 x: r.x + x,
2182 y: r.y + y,
2183 width: width.min(r.width),
2184 height: height.min(r.height),
2185 }
2186}
2187
2188fn render_help_modal(frame: &mut Frame, area: Rect) {
2189 let help_text = vec![
2190 Line::from(vec![Span::styled("⎋ ", red_text()), Span::raw(" Escape")]),
2191 Line::from(vec![
2192 Span::styled("⏎ ", red_text()),
2193 Span::raw(" Enter/Return"),
2194 ]),
2195 Line::from(vec![Span::styled("⇤⇥ ", red_text()), Span::raw(" Tab")]),
2196 Line::from(vec![Span::styled("␣ ", red_text()), Span::raw(" Space")]),
2197 Line::from(vec![Span::styled("^r ", red_text()), Span::raw(" Ctrl+r")]),
2198 Line::from(vec![Span::styled("^w ", red_text()), Span::raw(" Ctrl+w")]),
2199 Line::from(vec![Span::styled("^o ", red_text()), Span::raw(" Ctrl+o")]),
2200 Line::from(vec![Span::styled("^p ", red_text()), Span::raw(" Ctrl+p")]),
2201 Line::from(vec![
2202 Span::styled("^u ", red_text()),
2203 Span::raw(" Ctrl+u (page up)"),
2204 ]),
2205 Line::from(vec![
2206 Span::styled("^d ", red_text()),
2207 Span::raw(" Ctrl+d (page down)"),
2208 ]),
2209 Line::from(vec![
2210 Span::styled("[] ", red_text()),
2211 Span::raw(" [ and ] (switch tabs)"),
2212 ]),
2213 Line::from(vec![
2214 Span::styled("↑↓ ", red_text()),
2215 Span::raw(" Arrow up/down"),
2216 ]),
2217 Line::from(vec![
2218 Span::styled("←→ ", red_text()),
2219 Span::raw(" Arrow left/right"),
2220 ]),
2221 Line::from(""),
2222 Line::from(vec![
2223 Span::styled("Press ", Style::default()),
2224 Span::styled("⎋", red_text()),
2225 Span::styled(" or ", Style::default()),
2226 Span::styled("⏎", red_text()),
2227 Span::styled(" to close", Style::default()),
2228 ]),
2229 ];
2230
2231 let max_width = help_text
2233 .iter()
2234 .map(|line| {
2235 line.spans
2236 .iter()
2237 .map(|span| span.content.len())
2238 .sum::<usize>()
2239 })
2240 .max()
2241 .unwrap_or(80) as u16;
2242
2243 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));
2249 let popup_height = content_height.min(area.height.saturating_sub(4));
2250
2251 let popup_area = Rect {
2252 x: area.x + (area.width.saturating_sub(popup_width)) / 2,
2253 y: area.y + (area.height.saturating_sub(popup_height)) / 2,
2254 width: popup_width,
2255 height: popup_height,
2256 };
2257
2258 let paragraph = Paragraph::new(help_text)
2259 .block(
2260 Block::default()
2261 .title(Span::styled(
2262 " Help ",
2263 Style::default().add_modifier(Modifier::BOLD),
2264 ))
2265 .borders(Borders::ALL)
2266 .border_type(BorderType::Rounded)
2267 .border_type(BorderType::Rounded)
2268 .border_style(active_border())
2269 .padding(Padding::horizontal(1)),
2270 )
2271 .wrap(Wrap { trim: false });
2272
2273 frame.render_widget(Clear, popup_area);
2274 frame.render_widget(paragraph, popup_area);
2275}
2276
2277fn render_region_selector(frame: &mut Frame, app: &App, area: Rect) {
2278 let popup_area = centered_rect(60, 60, area);
2279
2280 let chunks = Layout::default()
2281 .direction(Direction::Vertical)
2282 .constraints([Constraint::Length(3), Constraint::Min(0)])
2283 .split(popup_area);
2284
2285 let cursor = "█";
2287 let filter_text = vec![Span::from(format!("{}{}", app.region_filter, cursor))];
2288 let filter = filter_area(filter_text, true);
2289
2290 let filtered = app.get_filtered_regions();
2292 let items: Vec<ListItem> = filtered
2293 .iter()
2294 .map(|r| {
2295 let latency_str = match r.latency_ms {
2296 Some(ms) => format!("({}ms)", ms),
2297 None => "(>1s)".to_string(),
2298 };
2299 let opt_in = if r.opt_in { "[opt-in] " } else { "" };
2300 let display = format!(
2301 "{} > {} > {} {}{}",
2302 r.group, r.name, r.code, opt_in, latency_str
2303 );
2304 ListItem::new(display)
2305 })
2306 .collect();
2307
2308 let list = List::new(items)
2309 .block(
2310 Block::default()
2311 .title(format_title("Regions"))
2312 .borders(Borders::ALL)
2313 .border_type(BorderType::Rounded)
2314 .border_type(BorderType::Rounded)
2315 .border_style(active_border()),
2316 )
2317 .highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White))
2318 .highlight_symbol("▶ ");
2319
2320 frame.render_widget(Clear, popup_area);
2321 frame.render_widget(filter, chunks[0]);
2322 frame.render_stateful_widget(
2323 list,
2324 chunks[1],
2325 &mut ratatui::widgets::ListState::default().with_selected(Some(app.region_picker_selected)),
2326 );
2327}
2328
2329fn render_profile_picker(frame: &mut Frame, app: &App, area: Rect) {
2330 crate::aws::render_profile_picker(frame, app, area, centered_rect);
2331}
2332
2333fn render_session_picker(frame: &mut Frame, app: &App, area: Rect) {
2334 crate::session::render_session_picker(frame, app, area, centered_rect);
2335}
2336
2337fn render_calendar_picker(frame: &mut Frame, app: &App, area: Rect) {
2338 use ratatui::widgets::calendar::{CalendarEventStore, Monthly};
2339
2340 let popup_area = centered_rect(50, 50, area);
2341
2342 let date = app
2343 .calendar_date
2344 .unwrap_or_else(|| time::OffsetDateTime::now_utc().date());
2345
2346 let field_name = match app.calendar_selecting {
2347 CalendarField::StartDate => "Start Date",
2348 CalendarField::EndDate => "End Date",
2349 };
2350
2351 let events = CalendarEventStore::today(
2352 Style::default()
2353 .add_modifier(Modifier::BOLD)
2354 .bg(Color::Blue),
2355 );
2356
2357 let calendar = Monthly::new(date, events)
2358 .block(
2359 Block::default()
2360 .title(format_title(&format!("Select {}", field_name)))
2361 .borders(Borders::ALL)
2362 .border_type(BorderType::Rounded)
2363 .border_type(BorderType::Rounded)
2364 .border_style(active_border()),
2365 )
2366 .show_weekdays_header(Style::new().bold().yellow())
2367 .show_month_header(Style::new().bold().green());
2368
2369 frame.render_widget(Clear, popup_area);
2370 frame.render_widget(calendar, popup_area);
2371}
2372
2373pub fn render_json_highlighted(
2375 frame: &mut Frame,
2376 area: Rect,
2377 json_text: &str,
2378 scroll_offset: usize,
2379 title: &str,
2380 is_active: bool,
2381) {
2382 let total_lines = json_text.lines().count();
2383 let line_num_width = total_lines.to_string().len().max(2);
2384
2385 let lines: Vec<Line> = json_text
2386 .lines()
2387 .enumerate()
2388 .skip(scroll_offset)
2389 .map(|(idx, line)| {
2390 let mut spans = Vec::new();
2391
2392 let line_num = format!("{:>width$} │ ", idx + 1, width = line_num_width);
2394 spans.push(Span::styled(line_num, Style::default().fg(Color::DarkGray)));
2395
2396 let trimmed = line.trim_start();
2397 let indent = line.len() - trimmed.len();
2398
2399 if indent > 0 {
2400 spans.push(Span::raw(" ".repeat(indent)));
2401 }
2402
2403 if trimmed.starts_with('"') && trimmed.contains(':') {
2404 if let Some(colon_pos) = trimmed.find(':') {
2405 spans.push(Span::styled(
2406 &trimmed[..colon_pos],
2407 Style::default().fg(Color::Blue),
2408 ));
2409 spans.push(Span::raw(&trimmed[colon_pos..]));
2410 } else {
2411 spans.push(Span::raw(trimmed));
2412 }
2413 } else if trimmed.starts_with('"') {
2414 spans.push(Span::styled(trimmed, Style::default().fg(Color::Green)));
2415 } else if trimmed.starts_with("true") || trimmed.starts_with("false") {
2416 spans.push(Span::styled(trimmed, Style::default().fg(Color::Yellow)));
2417 } else if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
2418 spans.push(Span::styled(trimmed, Style::default().fg(Color::Magenta)));
2419 } else {
2420 spans.push(Span::raw(trimmed));
2421 }
2422
2423 Line::from(spans)
2424 })
2425 .collect();
2426
2427 let block = titled_block(title).border_style(if is_active {
2428 active_border()
2429 } else {
2430 Style::default()
2431 });
2432
2433 frame.render_widget(Paragraph::new(lines).block(block), area);
2434
2435 if total_lines > 0 {
2436 render_scrollbar(
2437 frame,
2438 area.inner(Margin {
2439 vertical: 1,
2440 horizontal: 0,
2441 }),
2442 total_lines,
2443 scroll_offset,
2444 );
2445 }
2446}
2447
2448pub fn render_tags_section<F>(frame: &mut Frame, area: Rect, render_table: F)
2450where
2451 F: FnOnce(&mut Frame, Rect),
2452{
2453 render_table(frame, area);
2454}
2455
2456pub fn render_permissions_section<F>(
2458 frame: &mut Frame,
2459 area: Rect,
2460 _description: &str,
2461 render_table: F,
2462) where
2463 F: FnOnce(&mut Frame, Rect),
2464{
2465 render_table(frame, area);
2466}
2467
2468pub fn render_last_accessed_section<F>(
2470 frame: &mut Frame,
2471 area: Rect,
2472 _description: &str,
2473 _note: &str,
2474 render_table: F,
2475) where
2476 F: FnOnce(&mut Frame, Rect),
2477{
2478 render_table(frame, area);
2479}
2480
2481#[cfg(test)]
2482mod tests {
2483 use super::*;
2484 use crate::app::Service;
2485 use crate::app::Tab;
2486 use crate::ecr::image::Image as EcrImage;
2487 use crate::ecr::repo::Repository as EcrRepository;
2488 use crate::keymap::Action;
2489 use crate::lambda;
2490 use crate::ui::cw::logs::filtered_log_groups;
2491 use crate::ui::table::Column;
2492
2493 fn test_app() -> App {
2494 App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
2495 }
2496
2497 fn test_app_no_region() -> App {
2498 App::new_without_client("test".to_string(), None)
2499 }
2500
2501 #[test]
2502 fn test_expanded_content_wrapping_marks_continuation_lines() {
2503 let max_width = 50;
2505 let col_name = "Message: ";
2506 let value = "This is a very long message that will definitely exceed the maximum width and need to be wrapped";
2507 let full_line = format!("{}{}", col_name, value);
2508
2509 let mut lines = Vec::new();
2510
2511 if full_line.len() <= max_width {
2512 lines.push((full_line, true));
2513 } else {
2514 let first_chunk_len = max_width.min(full_line.len());
2515 lines.push((full_line[..first_chunk_len].to_string(), true));
2516
2517 let mut remaining = &full_line[first_chunk_len..];
2518 while !remaining.is_empty() {
2519 let take = max_width.min(remaining.len());
2520 lines.push((remaining[..take].to_string(), false));
2521 remaining = &remaining[take..];
2522 }
2523 }
2524
2525 assert!(lines[0].1);
2527 assert!(!lines[1].1);
2529 assert!(lines.len() > 1);
2530 }
2531
2532 #[test]
2533 fn test_expanded_content_short_line_not_wrapped() {
2534 let max_width = 100;
2535 let col_name = "Timestamp: ";
2536 let value = "2025-03-13 19:49:30 (UTC)";
2537 let full_line = format!("{}{}", col_name, value);
2538
2539 let mut lines = Vec::new();
2540
2541 if full_line.len() <= max_width {
2542 lines.push((full_line.clone(), true));
2543 } else {
2544 let first_chunk_len = max_width.min(full_line.len());
2545 lines.push((full_line[..first_chunk_len].to_string(), true));
2546
2547 let mut remaining = &full_line[first_chunk_len..];
2548 while !remaining.is_empty() {
2549 let take = max_width.min(remaining.len());
2550 lines.push((remaining[..take].to_string(), false));
2551 remaining = &remaining[take..];
2552 }
2553 }
2554
2555 assert_eq!(lines.len(), 1);
2557 assert!(lines[0].1);
2558 assert_eq!(lines[0].0, full_line);
2559 }
2560
2561 #[test]
2562 fn test_tabs_display_with_separator() {
2563 let tabs = [
2565 Tab {
2566 service: Service::CloudWatchLogGroups,
2567 title: "CloudWatch > Log Groups".to_string(),
2568 breadcrumb: "CloudWatch > Log Groups".to_string(),
2569 },
2570 Tab {
2571 service: Service::CloudWatchInsights,
2572 title: "CloudWatch > Logs Insights".to_string(),
2573 breadcrumb: "CloudWatch > Logs Insights".to_string(),
2574 },
2575 ];
2576
2577 let mut spans = Vec::new();
2578 for (i, tab) in tabs.iter().enumerate() {
2579 if i > 0 {
2580 spans.push(Span::raw(" ⋮ "));
2581 }
2582 spans.push(Span::raw(tab.title.clone()));
2583 }
2584
2585 assert_eq!(spans.len(), 3);
2587 assert_eq!(spans[1].content, " ⋮ ");
2588 }
2589
2590 #[test]
2591 fn test_current_tab_highlighted() {
2592 let tabs = [
2593 crate::app::Tab {
2594 service: Service::CloudWatchLogGroups,
2595 title: "CloudWatch > Log Groups".to_string(),
2596 breadcrumb: "CloudWatch > Log Groups".to_string(),
2597 },
2598 crate::app::Tab {
2599 service: Service::CloudWatchInsights,
2600 title: "CloudWatch > Logs Insights".to_string(),
2601 breadcrumb: "CloudWatch > Logs Insights".to_string(),
2602 },
2603 ];
2604 let current_tab = 1;
2605
2606 let mut spans = Vec::new();
2607 for (i, tab) in tabs.iter().enumerate() {
2608 if i > 0 {
2609 spans.push(Span::raw(" ⋮ "));
2610 }
2611 if i == current_tab {
2612 spans.push(Span::styled(
2613 tab.title.clone(),
2614 Style::default()
2615 .fg(Color::Yellow)
2616 .add_modifier(Modifier::BOLD),
2617 ));
2618 } else {
2619 spans.push(Span::raw(tab.title.clone()));
2620 }
2621 }
2622
2623 assert_eq!(spans[2].style.fg, Some(Color::Yellow));
2625 assert!(spans[2].style.add_modifier.contains(Modifier::BOLD));
2626 assert_eq!(spans[0].style.fg, None);
2628 }
2629
2630 #[test]
2631 fn test_lambda_application_update_complete_shows_green_checkmark() {
2632 let app = crate::lambda::Application {
2633 name: "test-stack".to_string(),
2634 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2635 .to_string(),
2636 description: "Test stack".to_string(),
2637 status: "UPDATE_COMPLETE".to_string(),
2638 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2639 };
2640
2641 let col = ApplicationColumn::Status;
2642 let (text, style) = col.render(&app);
2643 assert_eq!(text, "✅ UPDATE_COMPLETE");
2644 assert_eq!(style.fg, Some(Color::Green));
2645 }
2646
2647 #[test]
2648 fn test_lambda_application_create_complete_shows_green_checkmark() {
2649 let app = crate::lambda::Application {
2650 name: "test-stack".to_string(),
2651 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2652 .to_string(),
2653 description: "Test stack".to_string(),
2654 status: "CREATE_COMPLETE".to_string(),
2655 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2656 };
2657
2658 let col = ApplicationColumn::Status;
2659 let (text, style) = col.render(&app);
2660 assert_eq!(text, "✅ CREATE_COMPLETE");
2661 assert_eq!(style.fg, Some(Color::Green));
2662 }
2663
2664 #[test]
2665 fn test_lambda_application_other_status_shows_default() {
2666 let app = crate::lambda::Application {
2667 name: "test-stack".to_string(),
2668 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2669 .to_string(),
2670 description: "Test stack".to_string(),
2671 status: "UPDATE_IN_PROGRESS".to_string(),
2672 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2673 };
2674
2675 let col = ApplicationColumn::Status;
2676 let (text, style) = col.render(&app);
2677 assert_eq!(text, "ℹ️ UPDATE_IN_PROGRESS");
2678 assert_eq!(style.fg, Some(ratatui::style::Color::LightBlue));
2679 }
2680
2681 #[test]
2682 fn test_lambda_application_status_complete() {
2683 let app = crate::lambda::Application {
2684 name: "test-stack".to_string(),
2685 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2686 .to_string(),
2687 description: "Test stack".to_string(),
2688 status: "UPDATE_COMPLETE".to_string(),
2689 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2690 };
2691
2692 let col = ApplicationColumn::Status;
2693 let (text, style) = col.render(&app);
2694 assert_eq!(text, "✅ UPDATE_COMPLETE");
2695 assert_eq!(style.fg, Some(ratatui::style::Color::Green));
2696 }
2697
2698 #[test]
2699 fn test_lambda_application_status_failed() {
2700 let app = crate::lambda::Application {
2701 name: "test-stack".to_string(),
2702 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2703 .to_string(),
2704 description: "Test stack".to_string(),
2705 status: "UPDATE_FAILED".to_string(),
2706 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2707 };
2708
2709 let col = ApplicationColumn::Status;
2710 let (text, style) = col.render(&app);
2711 assert_eq!(text, "❌ UPDATE_FAILED");
2712 assert_eq!(style.fg, Some(ratatui::style::Color::Red));
2713 }
2714
2715 #[test]
2716 fn test_lambda_application_status_rollback() {
2717 let app = crate::lambda::Application {
2718 name: "test-stack".to_string(),
2719 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2720 .to_string(),
2721 description: "Test stack".to_string(),
2722 status: "UPDATE_ROLLBACK_IN_PROGRESS".to_string(),
2723 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2724 };
2725
2726 let col = ApplicationColumn::Status;
2727 let (text, style) = col.render(&app);
2728 assert_eq!(text, "❌ UPDATE_ROLLBACK_IN_PROGRESS");
2729 assert_eq!(style.fg, Some(ratatui::style::Color::Red));
2730 }
2731
2732 #[test]
2733 fn test_tab_picker_shows_breadcrumb_and_preview() {
2734 let tabs = [
2735 crate::app::Tab {
2736 service: crate::app::Service::CloudWatchLogGroups,
2737 title: "CloudWatch > Log Groups".to_string(),
2738 breadcrumb: "CloudWatch > Log Groups".to_string(),
2739 },
2740 crate::app::Tab {
2741 service: crate::app::Service::CloudWatchAlarms,
2742 title: "CloudWatch > Alarms".to_string(),
2743 breadcrumb: "CloudWatch > Alarms".to_string(),
2744 },
2745 ];
2746
2747 let selected_idx = 1;
2749 let selected_tab = &tabs[selected_idx];
2750 assert_eq!(selected_tab.breadcrumb, "CloudWatch > Alarms");
2751 assert_eq!(selected_tab.title, "CloudWatch > Alarms");
2752
2753 assert!(selected_tab.breadcrumb.contains("CloudWatch"));
2755 assert!(selected_tab.breadcrumb.contains("Alarms"));
2756 }
2757
2758 #[test]
2759 fn test_tab_picker_has_active_border() {
2760 let border_style = Style::default().fg(Color::Green);
2762 let border_type = BorderType::Plain;
2763
2764 assert_eq!(border_style.fg, Some(Color::Green));
2766 assert_eq!(border_type, BorderType::Plain);
2768 }
2769
2770 #[test]
2771 fn test_tab_picker_title_is_tabs() {
2772 let title = " Tabs ";
2774 assert_eq!(title.trim(), "Tabs");
2775 assert!(!title.contains("Open"));
2776 }
2777
2778 #[test]
2779 fn test_s3_bucket_tabs_no_count_in_tabs() {
2780 let general_purpose_tab = "General purpose buckets (All AWS Regions)";
2782 let directory_tab = "Directory buckets";
2783
2784 assert!(!general_purpose_tab.contains("(0)"));
2786 assert!(!general_purpose_tab.contains("(1)"));
2787 assert!(!directory_tab.contains("(0)"));
2788 assert!(!directory_tab.contains("(1)"));
2789
2790 let table_title = " General purpose buckets (42) ";
2792 assert!(table_title.contains("(42)"));
2793 }
2794
2795 #[test]
2796 fn test_s3_bucket_column_preferences_shows_bucket_columns() {
2797 use crate::app::S3BucketColumn;
2798
2799 let app = test_app();
2800
2801 assert_eq!(app.s3_bucket_column_ids.len(), 3);
2803 assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
2804
2805 assert_eq!(S3BucketColumn::Name.name(), "Name");
2807 assert_eq!(S3BucketColumn::Region.name(), "Region");
2808 assert_eq!(S3BucketColumn::CreationDate.name(), "Creation date");
2809 }
2810
2811 #[test]
2812 fn test_s3_bucket_columns_not_cloudwatch_columns() {
2813 let app = test_app();
2814
2815 let bucket_col_names: Vec<String> = app
2817 .s3_bucket_column_ids
2818 .iter()
2819 .filter_map(|id| BucketColumn::from_id(id).map(|c| c.name()))
2820 .collect();
2821 let log_col_names: Vec<String> = app
2822 .cw_log_group_column_ids
2823 .iter()
2824 .filter_map(|id| LogGroupColumn::from_id(id).map(|c| c.name().to_string()))
2825 .collect();
2826
2827 assert_ne!(bucket_col_names, log_col_names);
2829
2830 assert!(!bucket_col_names.contains(&"Log group".to_string()));
2832 assert!(!bucket_col_names.contains(&"Stored bytes".to_string()));
2833
2834 assert!(bucket_col_names.contains(&"Creation date".to_string()));
2836
2837 assert!(!bucket_col_names.contains(&"AWS Region".to_string()));
2839 }
2840
2841 #[test]
2842 fn test_s3_bucket_column_toggle() {
2843 use crate::app::Service;
2844
2845 let mut app = test_app();
2846 app.current_service = Service::S3Buckets;
2847
2848 assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
2850
2851 let col = app.s3_bucket_column_ids[1];
2853 if let Some(pos) = app
2854 .s3_bucket_visible_column_ids
2855 .iter()
2856 .position(|c| *c == col)
2857 {
2858 app.s3_bucket_visible_column_ids.remove(pos);
2859 }
2860
2861 assert_eq!(app.s3_bucket_visible_column_ids.len(), 2);
2862 assert!(!app
2863 .s3_bucket_visible_column_ids
2864 .contains(&"column.s3.bucket.region"));
2865
2866 app.s3_bucket_visible_column_ids.push(col);
2868 assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
2869 assert!(app
2870 .s3_bucket_visible_column_ids
2871 .contains(&"column.s3.bucket.region"));
2872 }
2873
2874 #[test]
2875 fn test_s3_preferences_dialog_title() {
2876 let title = " Preferences ";
2878 assert_eq!(title.trim(), "Preferences");
2879 assert!(!title.contains("Space"));
2880 assert!(!title.contains("toggle"));
2881 }
2882
2883 #[test]
2884 fn test_column_selector_mode_has_hotkey_hints() {
2885 let help = " ↑↓: scroll | ␣: toggle | esc: close ";
2887
2888 assert!(help.contains("␣: toggle"));
2890 assert!(help.contains("↑↓: scroll"));
2891 assert!(help.contains("esc: close"));
2892
2893 assert!(!help.contains("⏎"));
2895 assert!(!help.contains("^w"));
2896 }
2897
2898 #[test]
2899 fn test_date_range_title_no_hints() {
2900 let title = " Date range ";
2902
2903 assert!(!title.contains("Tab to switch"));
2905 assert!(!title.contains("Space to change"));
2906 assert!(!title.contains("("));
2907 assert!(!title.contains(")"));
2908 }
2909
2910 #[test]
2911 fn test_event_filter_mode_has_hints_in_status_bar() {
2912 let help = " tab: switch | ␣: change unit | enter: apply | esc: cancel | ctrl+w: close ";
2914
2915 assert!(help.contains("tab: switch"));
2917 assert!(help.contains("␣: change unit"));
2918 assert!(help.contains("enter: apply"));
2919 assert!(help.contains("esc: cancel"));
2920 }
2921
2922 #[test]
2923 fn test_s3_preferences_shows_all_columns() {
2924 let app = test_app();
2925
2926 assert_eq!(app.s3_bucket_column_ids.len(), 3);
2928
2929 assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
2931
2932 let names: Vec<String> = app
2934 .s3_bucket_column_ids
2935 .iter()
2936 .filter_map(|id| BucketColumn::from_id(id).map(|c| c.name()))
2937 .collect();
2938 assert_eq!(names, vec!["Name", "Region", "Creation date"]);
2939 }
2940
2941 #[test]
2942 fn test_s3_preferences_has_active_border() {
2943 use ratatui::style::Color;
2944
2945 let border_color = Color::Green;
2947 assert_eq!(border_color, Color::Green);
2948
2949 assert_ne!(border_color, Color::Cyan);
2951 }
2952
2953 #[test]
2954 fn test_s3_table_loses_focus_when_preferences_shown() {
2955 use crate::app::Service;
2956 use crate::keymap::Mode;
2957 use ratatui::style::Color;
2958
2959 let mut app = test_app();
2960 app.current_service = Service::S3Buckets;
2961
2962 app.mode = Mode::Normal;
2964 let is_active = app.mode != Mode::ColumnSelector;
2965 let border_color = if is_active {
2966 Color::Green
2967 } else {
2968 Color::White
2969 };
2970 assert_eq!(border_color, Color::Green);
2971
2972 app.mode = Mode::ColumnSelector;
2974 let is_active = app.mode != Mode::ColumnSelector;
2975 let border_color = if is_active {
2976 Color::Green
2977 } else {
2978 Color::White
2979 };
2980 assert_eq!(border_color, Color::White);
2981 }
2982
2983 #[test]
2984 fn test_s3_object_tabs_cleared_before_render() {
2985 }
2988
2989 #[test]
2990 fn test_s3_properties_tab_shows_bucket_info() {
2991 use crate::app::{S3ObjectTab, Service};
2992
2993 let mut app = test_app();
2994 app.current_service = Service::S3Buckets;
2995 app.s3_state.current_bucket = Some("test-bucket".to_string());
2996 app.s3_state.object_tab = S3ObjectTab::Properties;
2997
2998 assert_eq!(app.s3_state.object_tab, S3ObjectTab::Properties);
3000
3001 assert_eq!(app.s3_state.properties_scroll, 0);
3003 }
3004
3005 #[test]
3006 fn test_s3_properties_scrolling() {
3007 use crate::app::{S3ObjectTab, Service};
3008
3009 let mut app = test_app();
3010 app.current_service = Service::S3Buckets;
3011 app.s3_state.current_bucket = Some("test-bucket".to_string());
3012 app.s3_state.object_tab = S3ObjectTab::Properties;
3013
3014 assert_eq!(app.s3_state.properties_scroll, 0);
3016
3017 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_add(1);
3019 assert_eq!(app.s3_state.properties_scroll, 1);
3020
3021 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_add(1);
3022 assert_eq!(app.s3_state.properties_scroll, 2);
3023
3024 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
3026 assert_eq!(app.s3_state.properties_scroll, 1);
3027
3028 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
3029 assert_eq!(app.s3_state.properties_scroll, 0);
3030
3031 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
3033 assert_eq!(app.s3_state.properties_scroll, 0);
3034 }
3035
3036 #[test]
3037 fn test_s3_parent_prefix_cleared_before_render() {
3038 }
3041
3042 #[test]
3043 fn test_s3_empty_region_defaults_to_us_east_1() {
3044 let _app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
3045
3046 let empty_region = "";
3048 let bucket_region = if empty_region.is_empty() {
3049 "us-east-1"
3050 } else {
3051 empty_region
3052 };
3053 assert_eq!(bucket_region, "us-east-1");
3054
3055 let set_region = "us-west-2";
3057 let bucket_region = if set_region.is_empty() {
3058 "us-east-1"
3059 } else {
3060 set_region
3061 };
3062 assert_eq!(bucket_region, "us-west-2");
3063 }
3064
3065 #[test]
3066 fn test_s3_properties_has_multiple_blocks() {
3067 let block_count = 12;
3069 assert_eq!(block_count, 12);
3070
3071 }
3075
3076 #[test]
3077 fn test_s3_properties_tables_use_common_component() {
3078 let tags_columns = ["Key", "Value"];
3081 assert_eq!(tags_columns.len(), 2);
3082
3083 let tiering_columns = [
3085 "Name",
3086 "Status",
3087 "Scope",
3088 "Days to Archive",
3089 "Days to Deep Archive",
3090 ];
3091 assert_eq!(tiering_columns.len(), 5);
3092
3093 let events_columns = [
3095 "Name",
3096 "Event types",
3097 "Filters",
3098 "Destination type",
3099 "Destination",
3100 ];
3101 assert_eq!(events_columns.len(), 5);
3102 }
3103
3104 #[test]
3105 fn test_s3_properties_field_format() {
3106 use ratatui::style::{Modifier, Style};
3108 use ratatui::text::{Line, Span};
3109
3110 let label = Line::from(vec![Span::styled(
3111 "AWS Region",
3112 Style::default().add_modifier(Modifier::BOLD),
3113 )]);
3114 let value = Line::from("us-east-1");
3115
3116 assert!(label.spans[0].style.add_modifier.contains(Modifier::BOLD));
3118
3119 assert!(!value.spans[0].style.add_modifier.contains(Modifier::BOLD));
3121 }
3122
3123 #[test]
3124 fn test_s3_properties_has_scrollbar() {
3125 let total_height = 7 + 5 + 6 + 5 + 4 + 4 + 5 + 4 + 4 + 4 + 4 + 4;
3127 assert_eq!(total_height, 56);
3128
3129 let area_height = 40;
3131 assert!(total_height > area_height);
3132 }
3133
3134 #[test]
3135 fn test_s3_bucket_region_fetched_on_open() {
3136 let empty_region = "";
3141 assert!(empty_region.is_empty());
3142
3143 let fetched_region = "us-west-2";
3145 assert!(!fetched_region.is_empty());
3146 }
3147
3148 #[test]
3149 fn test_s3_filter_space_used_when_hidden() {
3150 let objects_chunks = 4;
3155 let other_chunks = 3;
3156
3157 assert_eq!(objects_chunks, 4);
3158 assert_eq!(other_chunks, 3);
3159 assert!(other_chunks < objects_chunks);
3160 }
3161
3162 #[test]
3163 fn test_s3_properties_scrollable() {
3164 let mut app = test_app();
3165
3166 assert_eq!(app.s3_state.properties_scroll, 0);
3168
3169 app.s3_state.properties_scroll += 1;
3171 assert_eq!(app.s3_state.properties_scroll, 1);
3172
3173 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
3175 assert_eq!(app.s3_state.properties_scroll, 0);
3176 }
3177
3178 #[test]
3179 fn test_s3_properties_scrollbar_conditional() {
3180 let content_height = 40;
3182 let small_viewport = 20;
3183 let large_viewport = 50;
3184
3185 assert!(content_height > small_viewport);
3187
3188 assert!(content_height < large_viewport);
3190 }
3191
3192 #[test]
3193 fn test_s3_tabs_visible_with_styling() {
3194 use ratatui::style::{Color, Modifier, Style};
3195 use ratatui::text::Span;
3196
3197 let active_style = Style::default()
3199 .fg(Color::Yellow)
3200 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
3201 let active_tab = Span::styled("Objects", active_style);
3202 assert_eq!(active_tab.style.fg, Some(Color::Yellow));
3203 assert!(active_tab.style.add_modifier.contains(Modifier::BOLD));
3204 assert!(active_tab.style.add_modifier.contains(Modifier::UNDERLINED));
3205
3206 let inactive_style = Style::default().fg(Color::Gray);
3208 let inactive_tab = Span::styled("Properties", inactive_style);
3209 assert_eq!(inactive_tab.style.fg, Some(Color::Gray));
3210 }
3211
3212 #[test]
3213 fn test_s3_properties_field_labels_bold() {
3214 use ratatui::style::{Modifier, Style};
3215 use ratatui::text::{Line, Span};
3216
3217 let label = Span::styled(
3219 "AWS Region: ",
3220 Style::default().add_modifier(Modifier::BOLD),
3221 );
3222 let value = Span::raw("us-east-1");
3223 let line = Line::from(vec![label.clone(), value.clone()]);
3224
3225 assert!(label.style.add_modifier.contains(Modifier::BOLD));
3227
3228 assert!(!value.style.add_modifier.contains(Modifier::BOLD));
3230
3231 assert_eq!(line.spans.len(), 2);
3233 }
3234
3235 #[test]
3236 fn test_session_picker_dialog_opaque() {
3237 }
3240
3241 #[test]
3242 fn test_status_bar_hotkey_format() {
3243 let separator = " ⋮ ";
3247 assert_eq!(separator, " ⋮ ");
3248
3249 let ctrl_key = "^r";
3251 assert!(ctrl_key.starts_with("^"));
3252 assert!(!ctrl_key.contains("ctrl+"));
3253 assert!(!ctrl_key.contains("ctrl-"));
3254
3255 let shift_key = "^R";
3257 assert!(shift_key.contains("^R"));
3258 assert!(!shift_key.contains("shift+"));
3259 assert!(!shift_key.contains("shift-"));
3260
3261 let old_separator = " | ";
3263 assert_ne!(separator, old_separator);
3264 }
3265
3266 #[test]
3267 fn test_space_key_uses_unicode_symbol() {
3268 let space_symbol = "␣";
3270 assert_eq!(space_symbol, "␣");
3271 assert_eq!(space_symbol.len(), 3); assert_ne!(space_symbol, "space");
3275 assert_ne!(space_symbol, "SPC");
3276 }
3277
3278 #[test]
3279 fn test_region_hotkey_uses_space_menu() {
3280 let region_hotkey = "␣→r";
3282 assert_eq!(region_hotkey, "␣→r");
3283
3284 assert_ne!(region_hotkey, "^R");
3286 assert_ne!(region_hotkey, "ctrl+shift+r");
3287 }
3288
3289 #[test]
3290 fn test_no_incorrect_hotkey_patterns_in_ui() {
3291 let source = include_str!("mod.rs");
3293
3294 let ui_code = if let Some(pos) = source.find("#[cfg(test)]") {
3296 &source[..pos]
3297 } else {
3298 source
3299 };
3300
3301 let space_text_pattern = r#"Span::styled("space""#;
3303 assert!(
3304 !ui_code.contains(space_text_pattern),
3305 "Found 'space' text in hotkey - should use ␣ symbol instead"
3306 );
3307
3308 let lines_with_ctrl_shift_r: Vec<_> = ui_code
3310 .lines()
3311 .enumerate()
3312 .filter(|(_, line)| {
3313 line.contains(r#"Span::styled("^R""#) && line.contains("Color::Red")
3314 })
3315 .collect();
3316
3317 assert!(
3318 lines_with_ctrl_shift_r.is_empty(),
3319 "Found ^R in hotkeys (should use ␣→r for region): {:?}",
3320 lines_with_ctrl_shift_r
3321 );
3322 }
3323
3324 #[test]
3325 fn test_region_only_in_space_menu_not_status_bar() {
3326 let source = include_str!("mod.rs");
3328
3329 let space_menu_start = source
3331 .find("fn render_space_menu")
3332 .expect("render_space_menu function not found");
3333 let space_menu_end = space_menu_start
3334 + source[space_menu_start..]
3335 .find("fn render_service_picker")
3336 .expect("render_service_picker not found");
3337 let space_menu_code = &source[space_menu_start..space_menu_end];
3338
3339 assert!(
3341 space_menu_code.contains(r#"Span::raw(" regions")"#),
3342 "Region must be in Space menu"
3343 );
3344
3345 let status_bar_start = source
3347 .find("fn render_bottom_bar")
3348 .expect("render_bottom_bar function not found");
3349 let status_bar_end = status_bar_start
3350 + source[status_bar_start..]
3351 .find("\nfn render_")
3352 .expect("Next function not found");
3353 let status_bar_code = &source[status_bar_start..status_bar_end];
3354
3355 assert!(
3357 !status_bar_code.contains(" region ⋮ "),
3358 "Region hotkey must NOT be in status bar - it's only in Space menu!"
3359 );
3360 assert!(
3361 !status_bar_code.contains("␣→r"),
3362 "Region hotkey (␣→r) must NOT be in status bar - it's only in Space menu!"
3363 );
3364 assert!(
3365 !status_bar_code.contains("^R"),
3366 "Region hotkey (^R) must NOT be in status bar - it's only in Space menu!"
3367 );
3368 }
3369
3370 #[test]
3371 fn test_s3_bucket_preview_permanent_redirect_handled() {
3372 let error_msg = "PermanentRedirect";
3375 assert!(error_msg.contains("PermanentRedirect"));
3376
3377 let mut preview_map: std::collections::HashMap<String, Vec<crate::app::S3Object>> =
3379 std::collections::HashMap::new();
3380 preview_map.insert("bucket".to_string(), vec![]);
3381 assert!(preview_map.contains_key("bucket"));
3382 }
3383
3384 #[test]
3385 fn test_s3_objects_hint_is_open() {
3386 let hint = "open";
3388 assert_eq!(hint, "open");
3389 assert_ne!(hint, "drill down");
3390 assert_ne!(hint, "open folder");
3391 }
3392
3393 #[test]
3394 fn test_s3_service_tabs_use_cyan() {
3395 let active_color = Color::Cyan;
3397 assert_eq!(active_color, Color::Cyan);
3398 assert_ne!(active_color, Color::Yellow);
3399 }
3400
3401 #[test]
3402 fn test_s3_column_names_use_orange() {
3403 let column_color = Color::LightRed;
3405 assert_eq!(column_color, Color::LightRed);
3406 }
3407
3408 #[test]
3409 fn test_s3_bucket_errors_shown_in_expanded_rows() {
3410 let mut errors: std::collections::HashMap<String, String> =
3412 std::collections::HashMap::new();
3413 errors.insert("bucket".to_string(), "Error message".to_string());
3414 assert!(errors.contains_key("bucket"));
3415 assert_eq!(errors.get("bucket").unwrap(), "Error message");
3416 }
3417
3418 #[test]
3419 fn test_cloudwatch_alarms_page_input() {
3420 let mut app = test_app();
3422 app.current_service = Service::CloudWatchAlarms;
3423 app.page_input = "2".to_string();
3424
3425 assert_eq!(app.page_input, "2");
3427 }
3428
3429 #[test]
3430 fn test_tabs_row_shows_profile_info() {
3431 let profile = "default";
3433 let account = "123456789012";
3434 let region = "us-west-2";
3435 let identity = "role:/MyRole";
3436
3437 let info = format!(
3438 "Profile: {} ⋮ Account: {} ⋮ Region: {} ⋮ Identity: {}",
3439 profile, account, region, identity
3440 );
3441 assert!(info.contains("Profile:"));
3442 assert!(info.contains("Account:"));
3443 assert!(info.contains("Region:"));
3444 assert!(info.contains("Identity:"));
3445 assert!(info.contains("⋮"));
3446 }
3447
3448 #[test]
3449 fn test_tabs_row_profile_labels_are_bold() {
3450 let label_style = Style::default()
3452 .fg(Color::White)
3453 .add_modifier(Modifier::BOLD);
3454 assert!(label_style.add_modifier.contains(Modifier::BOLD));
3455 }
3456
3457 #[test]
3458 fn test_profile_info_not_duplicated() {
3459 let breadcrumbs = "CloudWatch > Alarms";
3462 assert!(!breadcrumbs.contains("Profile:"));
3463 assert!(!breadcrumbs.contains("Account:"));
3464 }
3465
3466 #[test]
3467 fn test_s3_column_headers_are_cyan() {
3468 let header_style = Style::default()
3470 .fg(Color::Cyan)
3471 .add_modifier(Modifier::BOLD);
3472 assert_eq!(header_style.fg, Some(Color::Cyan));
3473 assert!(header_style.add_modifier.contains(Modifier::BOLD));
3474 }
3475
3476 #[test]
3477 fn test_s3_nested_objects_can_be_expanded() {
3478 let mut app = test_app();
3481 app.current_service = Service::S3Buckets;
3482 app.s3_state.current_bucket = Some("bucket".to_string());
3483
3484 app.s3_state.objects.push(crate::app::S3Object {
3486 key: "folder1/".to_string(),
3487 size: 0,
3488 last_modified: String::new(),
3489 is_prefix: true,
3490 storage_class: String::new(),
3491 });
3492
3493 app.s3_state
3495 .expanded_prefixes
3496 .insert("folder1/".to_string());
3497
3498 let nested = vec![crate::app::S3Object {
3500 key: "folder1/subfolder/".to_string(),
3501 size: 0,
3502 last_modified: String::new(),
3503 is_prefix: true,
3504 storage_class: String::new(),
3505 }];
3506 app.s3_state
3507 .prefix_preview
3508 .insert("folder1/".to_string(), nested);
3509
3510 app.s3_state.selected_object = 1;
3512
3513 assert!(app.s3_state.current_bucket.is_some());
3515 }
3516
3517 #[test]
3518 fn test_s3_nested_folder_shows_expand_indicator() {
3519 use crate::app::{S3Object, Service};
3520
3521 let mut app = test_app();
3522 app.current_service = Service::S3Buckets;
3523 app.s3_state.current_bucket = Some("test-bucket".to_string());
3524
3525 app.s3_state.objects = vec![S3Object {
3527 key: "parent/".to_string(),
3528 size: 0,
3529 last_modified: "2024-01-01T00:00:00Z".to_string(),
3530 is_prefix: true,
3531 storage_class: String::new(),
3532 }];
3533
3534 app.s3_state.expanded_prefixes.insert("parent/".to_string());
3536 app.s3_state.prefix_preview.insert(
3537 "parent/".to_string(),
3538 vec![S3Object {
3539 key: "parent/child/".to_string(),
3540 size: 0,
3541 last_modified: "2024-01-01T00:00:00Z".to_string(),
3542 is_prefix: true,
3543 storage_class: String::new(),
3544 }],
3545 );
3546
3547 let child = &app.s3_state.prefix_preview.get("parent/").unwrap()[0];
3549 let is_expanded = app.s3_state.expanded_prefixes.contains(&child.key);
3550 let indicator = if is_expanded { "▼ " } else { "▶ " };
3551 assert_eq!(indicator, "▶ ");
3552
3553 app.s3_state
3555 .expanded_prefixes
3556 .insert("parent/child/".to_string());
3557 let is_expanded = app.s3_state.expanded_prefixes.contains(&child.key);
3558 let indicator = if is_expanded { "▼ " } else { "▶ " };
3559 assert_eq!(indicator, "▼ ");
3560 }
3561
3562 #[test]
3563 fn test_tabs_row_always_visible() {
3564 let app = test_app();
3567 assert!(!app.service_selected); }
3570
3571 #[test]
3572 fn test_no_duplicate_breadcrumbs_at_root() {
3573 let mut app = test_app();
3575 app.current_service = Service::CloudWatchAlarms;
3576 app.service_selected = true;
3577 app.tabs.push(crate::app::Tab {
3578 service: Service::CloudWatchAlarms,
3579 title: "CloudWatch > Alarms".to_string(),
3580 breadcrumb: "CloudWatch > Alarms".to_string(),
3581 });
3582
3583 assert_eq!(app.breadcrumbs(), "CloudWatch > Alarms");
3586 }
3587
3588 #[test]
3589 fn test_preferences_headers_use_cyan_underline() {
3590 let header_style = Style::default()
3592 .fg(Color::Cyan)
3593 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
3594 assert_eq!(header_style.fg, Some(Color::Cyan));
3595 assert!(header_style.add_modifier.contains(Modifier::BOLD));
3596 assert!(header_style.add_modifier.contains(Modifier::UNDERLINED));
3597
3598 let header_text = "Columns";
3600 assert!(!header_text.contains("═"));
3601 }
3602
3603 #[test]
3604 fn test_alarm_pagination_shows_actual_pages() {
3605 let page_size = 10;
3607 let total_items = 25;
3608 let total_pages = (total_items + page_size - 1) / page_size;
3609 let current_page = 1;
3610
3611 let pagination = format!("Page {} of {}", current_page, total_pages);
3612 assert_eq!(pagination, "Page 1 of 3");
3613 assert!(!pagination.contains("[1]"));
3614 assert!(!pagination.contains("[2]"));
3615 }
3616
3617 #[test]
3618 fn test_mode_indicator_uses_insert_not_input() {
3619 let mode_text = " INSERT ";
3621 assert_eq!(mode_text, " INSERT ");
3622 assert_ne!(mode_text, " INPUT ");
3623 }
3624
3625 #[test]
3626 fn test_service_picker_shows_insert_mode_when_typing() {
3627 let mut app = test_app();
3629 app.mode = Mode::ServicePicker;
3630 app.service_picker.filter = "cloud".to_string();
3631
3632 assert!(!app.service_picker.filter.is_empty());
3634 }
3635
3636 #[test]
3637 fn test_log_events_no_horizontal_scrollbar() {
3638 let app = test_app();
3642
3643 assert_eq!(app.cw_log_event_visible_column_ids.len(), 2);
3646
3647 assert_eq!(app.log_groups_state.event_horizontal_scroll, 0);
3649 }
3650
3651 #[test]
3652 fn test_log_events_expansion_stays_visible_when_scrolling() {
3653 let mut app = test_app();
3656
3657 app.log_groups_state.expanded_event = Some(0);
3659 app.log_groups_state.event_scroll_offset = 0;
3660
3661 app.log_groups_state.event_scroll_offset = 1;
3663
3664 assert_eq!(app.log_groups_state.expanded_event, Some(0));
3666 }
3667
3668 #[test]
3669 fn test_log_events_right_arrow_expands() {
3670 let mut app = test_app();
3671 app.current_service = Service::CloudWatchLogGroups;
3672 app.service_selected = true;
3673 app.view_mode = ViewMode::Events;
3674
3675 app.log_groups_state.log_events = vec![rusticity_core::LogEvent {
3676 timestamp: chrono::Utc::now(),
3677 message: "Test log message".to_string(),
3678 }];
3679 app.log_groups_state.event_scroll_offset = 0;
3680
3681 assert_eq!(app.log_groups_state.expanded_event, None);
3682
3683 app.handle_action(Action::NextPane);
3685 assert_eq!(app.log_groups_state.expanded_event, Some(0));
3686 }
3687
3688 #[test]
3689 fn test_log_events_left_arrow_collapses() {
3690 let mut app = test_app();
3691 app.current_service = Service::CloudWatchLogGroups;
3692 app.service_selected = true;
3693 app.view_mode = ViewMode::Events;
3694
3695 app.log_groups_state.log_events = vec![rusticity_core::LogEvent {
3696 timestamp: chrono::Utc::now(),
3697 message: "Test log message".to_string(),
3698 }];
3699 app.log_groups_state.event_scroll_offset = 0;
3700 app.log_groups_state.expanded_event = Some(0);
3701
3702 app.handle_action(Action::PrevPane);
3704 assert_eq!(app.log_groups_state.expanded_event, None);
3705 }
3706
3707 #[test]
3708 fn test_log_events_expanded_content_replaces_tabs() {
3709 let message_with_tabs = "[INFO]\t2025-10-22T13:41:37.601Z\tb2227e1c";
3711 let cleaned = message_with_tabs.replace('\t', " ");
3712
3713 assert!(!cleaned.contains('\t'));
3714 assert!(cleaned.contains(" "));
3715 assert_eq!(cleaned, "[INFO] 2025-10-22T13:41:37.601Z b2227e1c");
3716 }
3717
3718 #[test]
3719 fn test_log_events_navigation_skips_expanded_overlay() {
3720 let mut app = test_app();
3723
3724 app.log_groups_state.expanded_event = Some(0);
3726 app.log_groups_state.event_scroll_offset = 0;
3727
3728 app.log_groups_state.event_scroll_offset = 1;
3730
3731 assert_eq!(app.log_groups_state.event_scroll_offset, 1);
3733
3734 assert_eq!(app.log_groups_state.expanded_event, Some(0));
3736 }
3737
3738 #[test]
3739 fn test_log_events_empty_rows_reserve_space_for_overlay() {
3740 let message = "Long message that will wrap across multiple lines when expanded";
3743 let max_width = 50;
3744
3745 let full_line = format!("Message: {}", message);
3747 let line_count = full_line.len().div_ceil(max_width);
3748
3749 assert!(line_count >= 2);
3751
3752 }
3755
3756 #[test]
3757 fn test_preferences_title_no_hints() {
3758 let s3_title = " Preferences ";
3761 let events_title = " Preferences ";
3762 let alarms_title = " Preferences ";
3763
3764 assert_eq!(s3_title.trim(), "Preferences");
3765 assert_eq!(events_title.trim(), "Preferences");
3766 assert_eq!(alarms_title.trim(), "Preferences");
3767
3768 assert!(!s3_title.contains("Space"));
3770 assert!(!events_title.contains("Space"));
3771 assert!(!alarms_title.contains("Tab"));
3772 }
3773
3774 #[test]
3775 fn test_page_navigation_works_for_events() {
3776 let mut app = test_app();
3778 app.view_mode = ViewMode::Events;
3779
3780 app.log_groups_state.event_scroll_offset = 0;
3782
3783 let page = 2;
3785 let page_size = 20;
3786 let target_index = (page - 1) * page_size;
3787
3788 assert_eq!(target_index, 20);
3789
3790 app.page_input.clear();
3792 assert!(app.page_input.is_empty());
3793 }
3794
3795 #[test]
3796 fn test_status_bar_shows_tab_hint_for_alarms_preferences() {
3797 let app = test_app();
3800
3801 assert_eq!(app.current_service, Service::CloudWatchLogGroups);
3804
3805 }
3808
3809 #[test]
3810 fn test_column_selector_shows_correct_columns_per_service() {
3811 use crate::app::Service;
3812
3813 let mut app = test_app();
3815 app.current_service = Service::S3Buckets;
3816 let bucket_col_names: Vec<String> = app
3817 .s3_bucket_column_ids
3818 .iter()
3819 .filter_map(|id| BucketColumn::from_id(id).map(|c| c.name()))
3820 .collect();
3821 assert_eq!(bucket_col_names, vec!["Name", "Region", "Creation date"]);
3822
3823 app.current_service = Service::CloudWatchLogGroups;
3825 let log_col_names: Vec<String> = app
3826 .cw_log_group_column_ids
3827 .iter()
3828 .filter_map(|id| LogGroupColumn::from_id(id).map(|c| c.name().to_string()))
3829 .collect();
3830 assert_eq!(
3831 log_col_names,
3832 vec![
3833 "Log group",
3834 "Log class",
3835 "Retention",
3836 "Stored bytes",
3837 "Creation time",
3838 "ARN"
3839 ]
3840 );
3841
3842 app.current_service = Service::CloudWatchAlarms;
3844 assert!(!app.cw_alarm_column_ids.is_empty());
3845 if let Some(col) = AlarmColumn::from_id(app.cw_alarm_column_ids[0]) {
3846 assert!(col.name().contains("Name") || col.name().contains("Alarm"));
3847 }
3848 }
3849
3850 #[test]
3851 fn test_log_groups_preferences_shows_all_six_columns() {
3852 use crate::app::Service;
3853
3854 let mut app = test_app();
3855 app.current_service = Service::CloudWatchLogGroups;
3856
3857 assert_eq!(app.cw_log_group_column_ids.len(), 6);
3859
3860 let col_names: Vec<String> = app
3862 .cw_log_group_column_ids
3863 .iter()
3864 .filter_map(|id| LogGroupColumn::from_id(id).map(|c| c.name().to_string()))
3865 .collect();
3866 assert!(col_names.iter().any(|n| n == "Log group"));
3867 assert!(col_names.iter().any(|n| n == "Log class"));
3868 assert!(col_names.iter().any(|n| n == "Retention"));
3869 assert!(col_names.iter().any(|n| n == "Stored bytes"));
3870 assert!(col_names.iter().any(|n| n == "Creation time"));
3871 assert!(col_names.iter().any(|n| n == "ARN"));
3872 }
3873
3874 #[test]
3875 fn test_stream_preferences_shows_all_columns() {
3876 use crate::app::ViewMode;
3877
3878 let mut app = test_app();
3879 app.view_mode = ViewMode::Detail;
3880
3881 assert!(!app.cw_log_stream_column_ids.is_empty());
3883 assert_eq!(app.cw_log_stream_column_ids.len(), 7);
3884 }
3885
3886 #[test]
3887 fn test_event_preferences_shows_all_columns() {
3888 use crate::app::ViewMode;
3889
3890 let mut app = test_app();
3891 app.view_mode = ViewMode::Events;
3892
3893 assert!(!app.cw_log_event_column_ids.is_empty());
3895 assert_eq!(app.cw_log_event_column_ids.len(), 5);
3896 }
3897
3898 #[test]
3899 fn test_alarm_preferences_shows_all_columns() {
3900 use crate::app::Service;
3901
3902 let mut app = test_app();
3903 app.current_service = Service::CloudWatchAlarms;
3904
3905 assert!(!app.cw_alarm_column_ids.is_empty());
3907 assert_eq!(app.cw_alarm_column_ids.len(), 16);
3908 }
3909
3910 #[test]
3911 fn test_column_selector_has_scrollbar() {
3912 let item_count = 6; assert!(item_count > 0);
3916
3917 }
3920
3921 #[test]
3922 fn test_preferences_scrollbar_only_when_needed() {
3923 let item_count = 6;
3925 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;
3931 assert!(!needs_scrollbar_fits);
3932
3933 let needs_scrollbar_doesnt_fit = height > max_height_doesnt_fit;
3935 assert!(needs_scrollbar_doesnt_fit);
3936 }
3937
3938 #[test]
3939 fn test_preferences_height_no_extra_padding() {
3940 let item_count = 6;
3942 let height = (item_count as u16 + 2).max(8);
3943 assert_eq!(height, 8); assert_ne!(height, 10); }
3948
3949 #[test]
3950 fn test_preferences_uses_absolute_sizing() {
3951 let width = 50u16; let height = 10u16; assert!(width <= 100); assert!(height <= 50); }
3960
3961 #[test]
3962 fn test_profile_picker_shows_sort_indicator() {
3963 let sort_column = "Profile";
3965 let sort_direction = "ASC";
3966
3967 assert_eq!(sort_column, "Profile");
3968 assert_eq!(sort_direction, "ASC");
3969
3970 let arrow = if sort_direction == "ASC" {
3972 " ↑"
3973 } else {
3974 " ↓"
3975 };
3976 assert_eq!(arrow, " ↑");
3977 }
3978
3979 #[test]
3980 fn test_session_picker_shows_sort_indicator() {
3981 let sort_column = "Timestamp";
3983 let sort_direction = "DESC";
3984
3985 assert_eq!(sort_column, "Timestamp");
3986 assert_eq!(sort_direction, "DESC");
3987
3988 let arrow = if sort_direction == "ASC" {
3990 " ↑"
3991 } else {
3992 " ↓"
3993 };
3994 assert_eq!(arrow, " ↓");
3995 }
3996
3997 #[test]
3998 fn test_profile_picker_sorted_ascending() {
3999 let mut app = test_app_no_region();
4000 app.available_profiles = vec![
4001 crate::app::AwsProfile {
4002 name: "zebra".to_string(),
4003 region: None,
4004 account: None,
4005 role_arn: None,
4006 source_profile: None,
4007 },
4008 crate::app::AwsProfile {
4009 name: "alpha".to_string(),
4010 region: None,
4011 account: None,
4012 role_arn: None,
4013 source_profile: None,
4014 },
4015 ];
4016
4017 let filtered = app.get_filtered_profiles();
4018 assert_eq!(filtered[0].name, "alpha");
4019 assert_eq!(filtered[1].name, "zebra");
4020 }
4021
4022 #[test]
4023 fn test_session_picker_sorted_descending() {
4024 let mut app = test_app_no_region();
4025 app.sessions = vec![
4027 crate::session::Session {
4028 id: "2".to_string(),
4029 timestamp: "2024-01-02 10:00:00 UTC".to_string(),
4030 profile: "new".to_string(),
4031 region: "us-east-1".to_string(),
4032 account_id: "123".to_string(),
4033 role_arn: String::new(),
4034 tabs: vec![],
4035 },
4036 crate::session::Session {
4037 id: "1".to_string(),
4038 timestamp: "2024-01-01 10:00:00 UTC".to_string(),
4039 profile: "old".to_string(),
4040 region: "us-east-1".to_string(),
4041 account_id: "123".to_string(),
4042 role_arn: String::new(),
4043 tabs: vec![],
4044 },
4045 ];
4046
4047 let filtered = app.get_filtered_sessions();
4048 assert_eq!(filtered[0].profile, "new");
4050 assert_eq!(filtered[1].profile, "old");
4051 }
4052
4053 #[test]
4054 fn test_ecr_encryption_type_aes256_renders_as_aes_dash_256() {
4055 let repo = EcrRepository {
4056 name: "test-repo".to_string(),
4057 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
4058 created_at: "2024-01-01".to_string(),
4059 tag_immutability: "MUTABLE".to_string(),
4060 encryption_type: "AES256".to_string(),
4061 };
4062
4063 let formatted = match repo.encryption_type.as_ref() {
4064 "AES256" => "AES-256".to_string(),
4065 "KMS" => "KMS".to_string(),
4066 other => other.to_string(),
4067 };
4068
4069 assert_eq!(formatted, "AES-256");
4070 }
4071
4072 #[test]
4073 fn test_ecr_encryption_type_kms_unchanged() {
4074 let repo = EcrRepository {
4075 name: "test-repo".to_string(),
4076 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
4077 created_at: "2024-01-01".to_string(),
4078 tag_immutability: "MUTABLE".to_string(),
4079 encryption_type: "KMS".to_string(),
4080 };
4081
4082 let formatted = match repo.encryption_type.as_ref() {
4083 "AES256" => "AES-256".to_string(),
4084 "KMS" => "KMS".to_string(),
4085 other => other.to_string(),
4086 };
4087
4088 assert_eq!(formatted, "KMS");
4089 }
4090
4091 #[test]
4092 fn test_ecr_repo_filter_active_removes_table_focus() {
4093 let mut app = test_app_no_region();
4094 app.current_service = Service::EcrRepositories;
4095 app.mode = Mode::FilterInput;
4096 app.ecr_state.repositories.items = vec![EcrRepository {
4097 name: "test-repo".to_string(),
4098 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
4099 created_at: "2024-01-01".to_string(),
4100 tag_immutability: "MUTABLE".to_string(),
4101 encryption_type: "AES256".to_string(),
4102 }];
4103
4104 assert_eq!(app.mode, Mode::FilterInput);
4106 }
4108
4109 #[test]
4110 fn test_ecr_image_filter_active_removes_table_focus() {
4111 let mut app = test_app_no_region();
4112 app.current_service = Service::EcrRepositories;
4113 app.ecr_state.current_repository = Some("test-repo".to_string());
4114 app.mode = Mode::FilterInput;
4115 app.ecr_state.images.items = vec![EcrImage {
4116 tag: "v1.0.0".to_string(),
4117 artifact_type: "application/vnd.docker.container.image.v1+json".to_string(),
4118 pushed_at: "2024-01-01".to_string(),
4119 size_bytes: 104857600,
4120 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo:v1.0.0".to_string(),
4121 digest: "sha256:abc123".to_string(),
4122 last_pull_time: "2024-01-02".to_string(),
4123 }];
4124
4125 assert_eq!(app.mode, Mode::FilterInput);
4127 }
4129
4130 #[test]
4131 fn test_ecr_filter_escape_returns_to_normal_mode() {
4132 let mut app = test_app_no_region();
4133 app.current_service = Service::EcrRepositories;
4134 app.mode = Mode::FilterInput;
4135 app.ecr_state.repositories.filter = "test".to_string();
4136
4137 app.handle_action(crate::keymap::Action::CloseMenu);
4139
4140 assert_eq!(app.mode, Mode::Normal);
4141 }
4142
4143 #[test]
4144 fn test_ecr_repos_no_scrollbar_when_all_fit() {
4145 let mut app = test_app_no_region();
4147 app.current_service = Service::EcrRepositories;
4148 app.ecr_state.repositories.items = (0..50)
4149 .map(|i| EcrRepository {
4150 name: format!("repo{}", i),
4151 uri: format!("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo{}", i),
4152 created_at: "2024-01-01".to_string(),
4153 tag_immutability: "MUTABLE".to_string(),
4154 encryption_type: "AES256".to_string(),
4155 })
4156 .collect();
4157
4158 let row_count = 50;
4161 let typical_area_height: u16 = 60;
4162 let available_height = typical_area_height.saturating_sub(3);
4163
4164 assert!(
4165 row_count <= available_height as usize,
4166 "50 repos should fit without scrollbar"
4167 );
4168 }
4169
4170 #[test]
4171 fn test_lambda_default_columns() {
4172 let app = test_app_no_region();
4173
4174 assert_eq!(app.lambda_state.function_visible_column_ids.len(), 6);
4175 assert_eq!(
4176 app.lambda_state.function_visible_column_ids[0],
4177 "column.lambda.function.name"
4178 );
4179 assert_eq!(
4180 app.lambda_state.function_visible_column_ids[1],
4181 "column.lambda.function.runtime"
4182 );
4183 assert_eq!(
4184 app.lambda_state.function_visible_column_ids[2],
4185 "column.lambda.function.code_size"
4186 );
4187 assert_eq!(
4188 app.lambda_state.function_visible_column_ids[3],
4189 "column.lambda.function.memory_mb"
4190 );
4191 assert_eq!(
4192 app.lambda_state.function_visible_column_ids[4],
4193 "column.lambda.function.timeout_seconds"
4194 );
4195 assert_eq!(
4196 app.lambda_state.function_visible_column_ids[5],
4197 "column.lambda.function.last_modified"
4198 );
4199 }
4200
4201 #[test]
4202 fn test_lambda_all_columns_available() {
4203 let all_columns = lambda::FunctionColumn::ids();
4204
4205 assert_eq!(all_columns.len(), 9);
4206 assert!(all_columns.contains(&"column.lambda.function.name"));
4207 assert!(all_columns.contains(&"column.lambda.function.description"));
4208 assert!(all_columns.contains(&"column.lambda.function.package_type"));
4209 assert!(all_columns.contains(&"column.lambda.function.runtime"));
4210 assert!(all_columns.contains(&"column.lambda.function.architecture"));
4211 assert!(all_columns.contains(&"column.lambda.function.code_size"));
4212 assert!(all_columns.contains(&"column.lambda.function.memory_mb"));
4213 assert!(all_columns.contains(&"column.lambda.function.timeout_seconds"));
4214 assert!(all_columns.contains(&"column.lambda.function.last_modified"));
4215 }
4216
4217 #[test]
4218 fn test_lambda_filter_active_removes_table_focus() {
4219 let mut app = test_app_no_region();
4220 app.current_service = Service::LambdaFunctions;
4221 app.mode = Mode::FilterInput;
4222 app.lambda_state.table.items = vec![lambda::Function {
4223 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4224 application: None,
4225 name: "test-function".to_string(),
4226 description: "Test function".to_string(),
4227 package_type: "Zip".to_string(),
4228 runtime: "python3.12".to_string(),
4229 architecture: "x86_64".to_string(),
4230 code_size: 1024,
4231 code_sha256: "test-sha256".to_string(),
4232 memory_mb: 128,
4233 timeout_seconds: 3,
4234 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
4235 layers: vec![],
4236 }];
4237
4238 assert_eq!(app.mode, Mode::FilterInput);
4239 }
4240
4241 #[test]
4242 fn test_lambda_default_page_size() {
4243 let app = test_app_no_region();
4244
4245 assert_eq!(app.lambda_state.table.page_size, PageSize::Fifty);
4246 assert_eq!(app.lambda_state.table.page_size.value(), 50);
4247 }
4248
4249 #[test]
4250 fn test_lambda_pagination() {
4251 let mut app = test_app_no_region();
4252 app.current_service = Service::LambdaFunctions;
4253 app.lambda_state.table.page_size = PageSize::Ten;
4254 app.lambda_state.table.items = (0..25)
4255 .map(|i| crate::app::LambdaFunction {
4256 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4257 application: None,
4258 name: format!("function-{}", i),
4259 description: format!("Function {}", i),
4260 package_type: "Zip".to_string(),
4261 runtime: "python3.12".to_string(),
4262 architecture: "x86_64".to_string(),
4263 code_size: 1024,
4264 code_sha256: "test-sha256".to_string(),
4265 memory_mb: 128,
4266 timeout_seconds: 3,
4267 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
4268 layers: vec![],
4269 })
4270 .collect();
4271
4272 let page_size = app.lambda_state.table.page_size.value();
4273 let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
4274
4275 assert_eq!(page_size, 10);
4276 assert_eq!(total_pages, 3);
4277 }
4278
4279 #[test]
4280 fn test_lambda_filter_by_name() {
4281 let mut app = test_app_no_region();
4282 app.lambda_state.table.items = vec![
4283 crate::app::LambdaFunction {
4284 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4285 application: None,
4286 name: "api-handler".to_string(),
4287 description: "API handler".to_string(),
4288 package_type: "Zip".to_string(),
4289 runtime: "python3.12".to_string(),
4290 architecture: "x86_64".to_string(),
4291 code_size: 1024,
4292 code_sha256: "test-sha256".to_string(),
4293 memory_mb: 128,
4294 timeout_seconds: 3,
4295 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
4296 layers: vec![],
4297 },
4298 crate::app::LambdaFunction {
4299 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4300 application: None,
4301 name: "data-processor".to_string(),
4302 description: "Data processor".to_string(),
4303 package_type: "Zip".to_string(),
4304 runtime: "nodejs20.x".to_string(),
4305 architecture: "arm64".to_string(),
4306 code_size: 2048,
4307 code_sha256: "test-sha256".to_string(),
4308 memory_mb: 256,
4309 timeout_seconds: 30,
4310 last_modified: "2024-01-02T00:00:00.000+0000".to_string(),
4311 layers: vec![],
4312 },
4313 ];
4314 app.lambda_state.table.filter = "api".to_string();
4315
4316 let filtered: Vec<_> = app
4317 .lambda_state
4318 .table
4319 .items
4320 .iter()
4321 .filter(|f| {
4322 app.lambda_state.table.filter.is_empty()
4323 || f.name
4324 .to_lowercase()
4325 .contains(&app.lambda_state.table.filter.to_lowercase())
4326 || f.description
4327 .to_lowercase()
4328 .contains(&app.lambda_state.table.filter.to_lowercase())
4329 || f.runtime
4330 .to_lowercase()
4331 .contains(&app.lambda_state.table.filter.to_lowercase())
4332 })
4333 .collect();
4334
4335 assert_eq!(filtered.len(), 1);
4336 assert_eq!(filtered[0].name, "api-handler");
4337 }
4338
4339 #[test]
4340 fn test_lambda_filter_by_runtime() {
4341 let mut app = test_app_no_region();
4342 app.lambda_state.table.items = vec![
4343 crate::app::LambdaFunction {
4344 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4345 application: None,
4346 name: "python-func".to_string(),
4347 description: "Python function".to_string(),
4348 package_type: "Zip".to_string(),
4349 runtime: "python3.12".to_string(),
4350 architecture: "x86_64".to_string(),
4351 code_size: 1024,
4352 code_sha256: "test-sha256".to_string(),
4353 memory_mb: 128,
4354 timeout_seconds: 3,
4355 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
4356 layers: vec![],
4357 },
4358 crate::app::LambdaFunction {
4359 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4360 application: None,
4361 name: "node-func".to_string(),
4362 description: "Node function".to_string(),
4363 package_type: "Zip".to_string(),
4364 runtime: "nodejs20.x".to_string(),
4365 architecture: "arm64".to_string(),
4366 code_size: 2048,
4367 code_sha256: "test-sha256".to_string(),
4368 memory_mb: 256,
4369 timeout_seconds: 30,
4370 last_modified: "2024-01-02T00:00:00.000+0000".to_string(),
4371 layers: vec![],
4372 },
4373 ];
4374 app.lambda_state.table.filter = "python".to_string();
4375
4376 let filtered: Vec<_> = app
4377 .lambda_state
4378 .table
4379 .items
4380 .iter()
4381 .filter(|f| {
4382 app.lambda_state.table.filter.is_empty()
4383 || f.name
4384 .to_lowercase()
4385 .contains(&app.lambda_state.table.filter.to_lowercase())
4386 || f.description
4387 .to_lowercase()
4388 .contains(&app.lambda_state.table.filter.to_lowercase())
4389 || f.runtime
4390 .to_lowercase()
4391 .contains(&app.lambda_state.table.filter.to_lowercase())
4392 })
4393 .collect();
4394
4395 assert_eq!(filtered.len(), 1);
4396 assert_eq!(filtered[0].runtime, "python3.12");
4397 }
4398
4399 #[test]
4400 fn test_lambda_page_size_changes_in_preferences() {
4401 let mut app = test_app_no_region();
4402 app.current_service = Service::LambdaFunctions;
4403 app.lambda_state.table.page_size = PageSize::Fifty;
4404
4405 app.mode = Mode::ColumnSelector;
4407 app.column_selector_index = 12; app.handle_action(crate::keymap::Action::ToggleColumn);
4410
4411 assert_eq!(app.lambda_state.table.page_size, PageSize::Ten);
4412 }
4413
4414 #[test]
4415 fn test_lambda_preferences_shows_page_sizes() {
4416 let app = test_app_no_region();
4417 let mut app = app;
4418 app.current_service = Service::LambdaFunctions;
4419
4420 let page_sizes = vec![
4422 PageSize::Ten,
4423 PageSize::TwentyFive,
4424 PageSize::Fifty,
4425 PageSize::OneHundred,
4426 ];
4427
4428 for size in page_sizes {
4429 app.lambda_state.table.page_size = size;
4430 assert_eq!(app.lambda_state.table.page_size, size);
4431 }
4432 }
4433
4434 #[test]
4435 fn test_lambda_pagination_respects_page_size() {
4436 let mut app = test_app_no_region();
4437 app.current_service = Service::LambdaFunctions;
4438 app.lambda_state.table.items = (0..100)
4439 .map(|i| crate::app::LambdaFunction {
4440 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4441 application: None,
4442 name: format!("function-{}", i),
4443 description: format!("Function {}", i),
4444 package_type: "Zip".to_string(),
4445 runtime: "python3.12".to_string(),
4446 architecture: "x86_64".to_string(),
4447 code_size: 1024,
4448 code_sha256: "test-sha256".to_string(),
4449 memory_mb: 128,
4450 timeout_seconds: 3,
4451 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
4452 layers: vec![],
4453 })
4454 .collect();
4455
4456 app.lambda_state.table.page_size = PageSize::Ten;
4458 let page_size = app.lambda_state.table.page_size.value();
4459 let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
4460 assert_eq!(page_size, 10);
4461 assert_eq!(total_pages, 10);
4462
4463 app.lambda_state.table.page_size = PageSize::TwentyFive;
4465 let page_size = app.lambda_state.table.page_size.value();
4466 let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
4467 assert_eq!(page_size, 25);
4468 assert_eq!(total_pages, 4);
4469
4470 app.lambda_state.table.page_size = PageSize::Fifty;
4472 let page_size = app.lambda_state.table.page_size.value();
4473 let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
4474 assert_eq!(page_size, 50);
4475 assert_eq!(total_pages, 2);
4476 }
4477
4478 #[test]
4479 fn test_lambda_next_preferences_cycles_sections() {
4480 let mut app = test_app_no_region();
4481 app.current_service = Service::LambdaFunctions;
4482 app.mode = Mode::ColumnSelector;
4483
4484 app.column_selector_index = 0;
4486 app.handle_action(crate::keymap::Action::NextPreferences);
4487
4488 assert_eq!(app.column_selector_index, 11);
4490
4491 app.handle_action(crate::keymap::Action::NextPreferences);
4493 assert_eq!(app.column_selector_index, 0);
4494 }
4495
4496 #[test]
4497 fn test_lambda_drill_down_on_enter() {
4498 let mut app = test_app_no_region();
4499 app.current_service = Service::LambdaFunctions;
4500 app.service_selected = true;
4501 app.mode = Mode::Normal;
4502 app.lambda_state.table.items = vec![crate::app::LambdaFunction {
4503 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4504 application: None,
4505 name: "test-function".to_string(),
4506 description: "Test function".to_string(),
4507 package_type: "Zip".to_string(),
4508 runtime: "python3.12".to_string(),
4509 architecture: "x86_64".to_string(),
4510 code_size: 1024,
4511 code_sha256: "test-sha256".to_string(),
4512 memory_mb: 128,
4513 timeout_seconds: 3,
4514 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4515 layers: vec![],
4516 }];
4517 app.lambda_state.table.selected = 0;
4518
4519 app.handle_action(crate::keymap::Action::Select);
4521
4522 assert_eq!(
4523 app.lambda_state.current_function,
4524 Some("test-function".to_string())
4525 );
4526 assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
4527 }
4528
4529 #[test]
4530 fn test_lambda_go_back_from_detail() {
4531 let mut app = test_app_no_region();
4532 app.current_service = Service::LambdaFunctions;
4533 app.lambda_state.current_function = Some("test-function".to_string());
4534
4535 app.handle_action(crate::keymap::Action::GoBack);
4536
4537 assert_eq!(app.lambda_state.current_function, None);
4538 }
4539
4540 #[test]
4541 fn test_lambda_detail_tab_cycling() {
4542 let mut app = test_app_no_region();
4543 app.current_service = Service::LambdaFunctions;
4544 app.lambda_state.current_function = Some("test-function".to_string());
4545 app.lambda_state.detail_tab = LambdaDetailTab::Code;
4546
4547 app.handle_action(crate::keymap::Action::NextDetailTab);
4548 assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Monitor);
4549
4550 app.handle_action(crate::keymap::Action::NextDetailTab);
4551 assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Configuration);
4552
4553 app.handle_action(crate::keymap::Action::NextDetailTab);
4554 assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Aliases);
4555
4556 app.handle_action(crate::keymap::Action::NextDetailTab);
4557 assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Versions);
4558
4559 app.handle_action(crate::keymap::Action::NextDetailTab);
4560 assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
4561 }
4562
4563 #[test]
4564 fn test_lambda_breadcrumbs_with_function_name() {
4565 let mut app = test_app_no_region();
4566 app.current_service = Service::LambdaFunctions;
4567 app.service_selected = true;
4568
4569 let breadcrumb = app.breadcrumbs();
4571 assert_eq!(breadcrumb, "Lambda > Functions");
4572
4573 app.lambda_state.current_function = Some("my-function".to_string());
4575 let breadcrumb = app.breadcrumbs();
4576 assert_eq!(breadcrumb, "Lambda > my-function");
4577 }
4578
4579 #[test]
4580 fn test_lambda_console_url() {
4581 let mut app = test_app_no_region();
4582 app.current_service = Service::LambdaFunctions;
4583 app.config.region = "us-east-1".to_string();
4584
4585 let url = app.get_console_url();
4587 assert_eq!(
4588 url,
4589 "https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions"
4590 );
4591
4592 app.lambda_state.current_function = Some("my-function".to_string());
4594 let url = app.get_console_url();
4595 assert_eq!(
4596 url,
4597 "https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions/my-function"
4598 );
4599 }
4600
4601 #[test]
4602 fn test_lambda_last_modified_format() {
4603 let func = crate::app::LambdaFunction {
4604 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4605 application: None,
4606 name: "test-function".to_string(),
4607 description: "Test function".to_string(),
4608 package_type: "Zip".to_string(),
4609 runtime: "python3.12".to_string(),
4610 architecture: "x86_64".to_string(),
4611 code_size: 1024,
4612 code_sha256: "test-sha256".to_string(),
4613 memory_mb: 128,
4614 timeout_seconds: 3,
4615 last_modified: "2024-01-01 12:30:45 (UTC)".to_string(),
4616 layers: vec![],
4617 };
4618
4619 assert!(func.last_modified.contains("(UTC)"));
4621 assert!(func.last_modified.contains("2024-01-01"));
4622 }
4623
4624 #[test]
4625 fn test_lambda_expand_on_right_arrow() {
4626 let mut app = test_app_no_region();
4627 app.current_service = Service::LambdaFunctions;
4628 app.service_selected = true;
4629 app.mode = Mode::Normal;
4630 app.lambda_state.table.items = vec![crate::app::LambdaFunction {
4631 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4632 application: None,
4633 name: "test-function".to_string(),
4634 description: "Test function".to_string(),
4635 package_type: "Zip".to_string(),
4636 runtime: "python3.12".to_string(),
4637 architecture: "x86_64".to_string(),
4638 code_size: 1024,
4639 code_sha256: "test-sha256".to_string(),
4640 memory_mb: 128,
4641 timeout_seconds: 3,
4642 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4643 layers: vec![],
4644 }];
4645 app.lambda_state.table.selected = 0;
4646
4647 app.handle_action(crate::keymap::Action::NextPane);
4648
4649 assert_eq!(app.lambda_state.table.expanded_item, Some(0));
4650 }
4651
4652 #[test]
4653 fn test_lambda_collapse_on_left_arrow() {
4654 let mut app = test_app_no_region();
4655 app.current_service = Service::LambdaFunctions;
4656 app.service_selected = true;
4657 app.mode = Mode::Normal;
4658 app.lambda_state.current_function = None; app.lambda_state.table.expanded_item = Some(0);
4660
4661 app.handle_action(crate::keymap::Action::PrevPane);
4662
4663 assert_eq!(app.lambda_state.table.expanded_item, None);
4664 }
4665
4666 #[test]
4667 fn test_lambda_filter_activation() {
4668 let mut app = test_app_no_region();
4669 app.current_service = Service::LambdaFunctions;
4670 app.service_selected = true;
4671 app.mode = Mode::Normal;
4672
4673 app.handle_action(crate::keymap::Action::StartFilter);
4674
4675 assert_eq!(app.mode, Mode::FilterInput);
4676 }
4677
4678 #[test]
4679 fn test_lambda_filter_backspace() {
4680 let mut app = test_app_no_region();
4681 app.current_service = Service::LambdaFunctions;
4682 app.mode = Mode::FilterInput;
4683 app.lambda_state.table.filter = "test".to_string();
4684
4685 app.handle_action(crate::keymap::Action::FilterBackspace);
4686
4687 assert_eq!(app.lambda_state.table.filter, "tes");
4688 }
4689
4690 #[test]
4691 fn test_lambda_sorted_by_last_modified_desc() {
4692 let func1 = crate::app::LambdaFunction {
4693 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4694 application: None,
4695 name: "func1".to_string(),
4696 description: String::new(),
4697 package_type: "Zip".to_string(),
4698 runtime: "python3.12".to_string(),
4699 architecture: "x86_64".to_string(),
4700 code_size: 1024,
4701 code_sha256: "test-sha256".to_string(),
4702 memory_mb: 128,
4703 timeout_seconds: 3,
4704 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4705 layers: vec![],
4706 };
4707 let func2 = crate::app::LambdaFunction {
4708 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4709 application: None,
4710 name: "func2".to_string(),
4711 description: String::new(),
4712 package_type: "Zip".to_string(),
4713 runtime: "python3.12".to_string(),
4714 architecture: "x86_64".to_string(),
4715 code_size: 1024,
4716 code_sha256: "test-sha256".to_string(),
4717 memory_mb: 128,
4718 timeout_seconds: 3,
4719 last_modified: "2024-12-31 00:00:00 (UTC)".to_string(),
4720 layers: vec![],
4721 };
4722
4723 let mut functions = [func1.clone(), func2.clone()].to_vec();
4724 functions.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
4725
4726 assert_eq!(functions[0].name, "func2");
4728 assert_eq!(functions[1].name, "func1");
4729 }
4730
4731 #[test]
4732 fn test_lambda_code_properties_has_sha256() {
4733 let func = crate::app::LambdaFunction {
4734 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4735 application: None,
4736 name: "test-function".to_string(),
4737 description: "Test".to_string(),
4738 package_type: "Zip".to_string(),
4739 runtime: "python3.12".to_string(),
4740 architecture: "x86_64".to_string(),
4741 code_size: 2600,
4742 code_sha256: "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE=".to_string(),
4743 memory_mb: 128,
4744 timeout_seconds: 3,
4745 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4746 layers: vec![],
4747 };
4748
4749 assert!(!func.code_sha256.is_empty());
4750 assert_eq!(
4751 func.code_sha256,
4752 "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE="
4753 );
4754 }
4755
4756 #[test]
4757 fn test_lambda_name_column_has_expand_symbol() {
4758 let func = crate::app::LambdaFunction {
4759 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4760 application: None,
4761 name: "test-function".to_string(),
4762 description: "Test".to_string(),
4763 package_type: "Zip".to_string(),
4764 runtime: "python3.12".to_string(),
4765 architecture: "x86_64".to_string(),
4766 code_size: 1024,
4767 code_sha256: "test-sha256".to_string(),
4768 memory_mb: 128,
4769 timeout_seconds: 3,
4770 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4771 layers: vec![],
4772 };
4773
4774 let symbol_collapsed = crate::ui::table::CURSOR_COLLAPSED;
4776 let rendered_collapsed = format!("{} {}", symbol_collapsed, func.name);
4777 assert!(rendered_collapsed.contains(symbol_collapsed));
4778 assert!(rendered_collapsed.contains("test-function"));
4779
4780 let symbol_expanded = crate::ui::table::CURSOR_EXPANDED;
4782 let rendered_expanded = format!("{} {}", symbol_expanded, func.name);
4783 assert!(rendered_expanded.contains(symbol_expanded));
4784 assert!(rendered_expanded.contains("test-function"));
4785
4786 assert_ne!(symbol_collapsed, symbol_expanded);
4788 }
4789
4790 #[test]
4791 fn test_lambda_last_modified_column_width() {
4792 let timestamp = "2025-10-31 08:37:46 (UTC)";
4794 assert_eq!(timestamp.len(), 25);
4795
4796 let width = 27u16;
4798 assert!(width >= timestamp.len() as u16);
4799 }
4800
4801 #[test]
4802 fn test_lambda_code_properties_has_info_and_kms_sections() {
4803 let mut app = test_app_no_region();
4804 app.current_service = Service::LambdaFunctions;
4805 app.lambda_state.current_function = Some("test-function".to_string());
4806 app.lambda_state.detail_tab = LambdaDetailTab::Code;
4807 app.lambda_state.table.items = vec![crate::app::LambdaFunction {
4808 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4809 application: None,
4810 name: "test-function".to_string(),
4811 description: "Test".to_string(),
4812 package_type: "Zip".to_string(),
4813 runtime: "python3.12".to_string(),
4814 architecture: "x86_64".to_string(),
4815 code_size: 2600,
4816 code_sha256: "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE=".to_string(),
4817 memory_mb: 128,
4818 timeout_seconds: 3,
4819 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4820 layers: vec![],
4821 }];
4822
4823 assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
4825
4826 assert!(app.lambda_state.current_function.is_some());
4828 assert_eq!(app.lambda_state.table.items.len(), 1);
4829
4830 let func = &app.lambda_state.table.items[0];
4832 assert!(!func.code_sha256.is_empty());
4833 assert!(!func.last_modified.is_empty());
4834 assert!(func.code_size > 0);
4835 }
4836
4837 #[test]
4838 fn test_lambda_pagination_navigation() {
4839 let mut app = test_app_no_region();
4840 app.current_service = Service::LambdaFunctions;
4841 app.service_selected = true;
4842 app.mode = Mode::Normal;
4843 app.lambda_state.table.page_size = PageSize::Ten;
4844
4845 app.lambda_state.table.items = (0..25)
4847 .map(|i| crate::app::LambdaFunction {
4848 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4849 application: None,
4850 name: format!("function-{}", i),
4851 description: "Test".to_string(),
4852 package_type: "Zip".to_string(),
4853 runtime: "python3.12".to_string(),
4854 architecture: "x86_64".to_string(),
4855 code_size: 1024,
4856 code_sha256: "test-sha256".to_string(),
4857 memory_mb: 128,
4858 timeout_seconds: 3,
4859 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4860 layers: vec![],
4861 })
4862 .collect();
4863
4864 app.lambda_state.table.selected = 0;
4866 let page_size = app.lambda_state.table.page_size.value();
4867 let current_page = app.lambda_state.table.selected / page_size;
4868 assert_eq!(current_page, 0);
4869 assert_eq!(app.lambda_state.table.selected % page_size, 0);
4870
4871 app.lambda_state.table.selected = 10;
4873 let current_page = app.lambda_state.table.selected / page_size;
4874 assert_eq!(current_page, 1);
4875 assert_eq!(app.lambda_state.table.selected % page_size, 0);
4876
4877 app.lambda_state.table.selected = 15;
4879 let current_page = app.lambda_state.table.selected / page_size;
4880 assert_eq!(current_page, 1);
4881 assert_eq!(app.lambda_state.table.selected % page_size, 5);
4882 }
4883
4884 #[test]
4885 fn test_lambda_pagination_with_100_functions() {
4886 let mut app = test_app_no_region();
4887 app.current_service = Service::LambdaFunctions;
4888 app.service_selected = true;
4889 app.mode = Mode::Normal;
4890 app.lambda_state.table.page_size = PageSize::Fifty;
4891
4892 app.lambda_state.table.items = (0..100)
4894 .map(|i| crate::app::LambdaFunction {
4895 arn: format!("arn:aws:lambda:us-east-1:123456789012:function:func-{}", i),
4896 application: None,
4897 name: format!("function-{:03}", i),
4898 description: format!("Function {}", i),
4899 package_type: "Zip".to_string(),
4900 runtime: "python3.12".to_string(),
4901 architecture: "x86_64".to_string(),
4902 code_size: 1024 + i,
4903 code_sha256: format!("sha256-{}", i),
4904 memory_mb: 128,
4905 timeout_seconds: 3,
4906 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4907 layers: vec![],
4908 })
4909 .collect();
4910
4911 let page_size = app.lambda_state.table.page_size.value();
4912 assert_eq!(page_size, 50);
4913
4914 app.lambda_state.table.selected = 0;
4916 let current_page = app.lambda_state.table.selected / page_size;
4917 assert_eq!(current_page, 0);
4918
4919 app.lambda_state.table.selected = 49;
4920 let current_page = app.lambda_state.table.selected / page_size;
4921 assert_eq!(current_page, 0);
4922
4923 app.lambda_state.table.selected = 50;
4925 let current_page = app.lambda_state.table.selected / page_size;
4926 assert_eq!(current_page, 1);
4927
4928 app.lambda_state.table.selected = 99;
4929 let current_page = app.lambda_state.table.selected / page_size;
4930 assert_eq!(current_page, 1);
4931
4932 let filtered_count = app.lambda_state.table.items.len();
4934 let total_pages = filtered_count.div_ceil(page_size);
4935 assert_eq!(total_pages, 2);
4936 }
4937
4938 #[test]
4939 fn test_pagination_color_matches_border_color() {
4940 use ratatui::style::{Color, Style};
4941
4942 let is_filter_input = false;
4944 let pagination_style = if is_filter_input {
4945 Style::default()
4946 } else {
4947 Style::default().fg(Color::Green)
4948 };
4949 let border_style = if is_filter_input {
4950 Style::default().fg(Color::Yellow)
4951 } else {
4952 Style::default()
4953 };
4954 assert_eq!(pagination_style.fg, Some(Color::Green));
4955 assert_eq!(border_style.fg, None); let is_filter_input = true;
4959 let pagination_style = if is_filter_input {
4960 Style::default()
4961 } else {
4962 Style::default().fg(Color::Green)
4963 };
4964 let border_style = if is_filter_input {
4965 Style::default().fg(Color::Yellow)
4966 } else {
4967 Style::default()
4968 };
4969 assert_eq!(pagination_style.fg, None); assert_eq!(border_style.fg, Some(Color::Yellow));
4971 }
4972
4973 #[test]
4974 fn test_lambda_application_expansion_indicator() {
4975 let app_name = "my-application";
4977
4978 let collapsed = crate::ui::table::format_expandable(app_name, false);
4980 assert!(collapsed.contains(crate::ui::table::CURSOR_COLLAPSED));
4981 assert!(collapsed.contains(app_name));
4982
4983 let expanded = crate::ui::table::format_expandable(app_name, true);
4985 assert!(expanded.contains(crate::ui::table::CURSOR_EXPANDED));
4986 assert!(expanded.contains(app_name));
4987 }
4988
4989 #[test]
4990 fn test_ecr_repository_selection_uses_table_state_page_size() {
4991 let mut app = test_app_no_region();
4993 app.current_service = Service::EcrRepositories;
4994
4995 app.ecr_state.repositories.items = (0..100)
4997 .map(|i| crate::ecr::repo::Repository {
4998 name: format!("repo{}", i),
4999 uri: format!("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo{}", i),
5000 created_at: "2024-01-01".to_string(),
5001 tag_immutability: "MUTABLE".to_string(),
5002 encryption_type: "AES256".to_string(),
5003 })
5004 .collect();
5005
5006 app.ecr_state.repositories.page_size = crate::common::PageSize::TwentyFive;
5008
5009 app.ecr_state.repositories.selected = 30;
5011
5012 let page_size = app.ecr_state.repositories.page_size.value();
5013 let selected_index = app.ecr_state.repositories.selected % page_size;
5014
5015 assert_eq!(page_size, 25);
5016 assert_eq!(selected_index, 5); }
5018
5019 #[test]
5020 fn test_ecr_repository_selection_indicator_visible() {
5021 let mut app = test_app_no_region();
5023 app.current_service = Service::EcrRepositories;
5024 app.mode = crate::keymap::Mode::Normal;
5025
5026 app.ecr_state.repositories.items = vec![
5027 crate::ecr::repo::Repository {
5028 name: "repo1".to_string(),
5029 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo1".to_string(),
5030 created_at: "2024-01-01".to_string(),
5031 tag_immutability: "MUTABLE".to_string(),
5032 encryption_type: "AES256".to_string(),
5033 },
5034 crate::ecr::repo::Repository {
5035 name: "repo2".to_string(),
5036 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo2".to_string(),
5037 created_at: "2024-01-02".to_string(),
5038 tag_immutability: "IMMUTABLE".to_string(),
5039 encryption_type: "KMS".to_string(),
5040 },
5041 ];
5042
5043 app.ecr_state.repositories.selected = 1;
5044
5045 let page_size = app.ecr_state.repositories.page_size.value();
5046 let selected_index = app.ecr_state.repositories.selected % page_size;
5047
5048 let is_active = app.mode != crate::keymap::Mode::FilterInput;
5050
5051 assert_eq!(selected_index, 1);
5052 assert!(is_active);
5053 }
5054
5055 #[test]
5056 fn test_ecr_repository_shows_expandable_indicator() {
5057 let repo = crate::ecr::repo::Repository {
5059 name: "test-repo".to_string(),
5060 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
5061 created_at: "2024-01-01".to_string(),
5062 tag_immutability: "MUTABLE".to_string(),
5063 encryption_type: "AES256".to_string(),
5064 };
5065
5066 let collapsed = crate::ui::table::format_expandable(&repo.name, false);
5068 assert!(collapsed.contains(crate::ui::table::CURSOR_COLLAPSED));
5069 assert!(collapsed.contains("test-repo"));
5070
5071 let expanded = crate::ui::table::format_expandable(&repo.name, true);
5073 assert!(expanded.contains(crate::ui::table::CURSOR_EXPANDED));
5074 assert!(expanded.contains("test-repo"));
5075 }
5076
5077 #[test]
5078 fn test_lambda_application_expanded_status_formatting() {
5079 let app = lambda::Application {
5081 name: "test-app".to_string(),
5082 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-app/abc123".to_string(),
5083 description: "Test application".to_string(),
5084 status: "UpdateComplete".to_string(),
5085 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
5086 };
5087
5088 let status_upper = app.status.to_uppercase();
5089 let formatted = if status_upper.contains("UPDATECOMPLETE")
5090 || status_upper.contains("UPDATE_COMPLETE")
5091 {
5092 "✅ Update complete"
5093 } else if status_upper.contains("CREATECOMPLETE")
5094 || status_upper.contains("CREATE_COMPLETE")
5095 {
5096 "✅ Create complete"
5097 } else {
5098 &app.status
5099 };
5100
5101 assert_eq!(formatted, "✅ Update complete");
5102
5103 let app2 = lambda::Application {
5105 status: "CreateComplete".to_string(),
5106 ..app
5107 };
5108 let status_upper = app2.status.to_uppercase();
5109 let formatted = if status_upper.contains("UPDATECOMPLETE")
5110 || status_upper.contains("UPDATE_COMPLETE")
5111 {
5112 "✅ Update complete"
5113 } else if status_upper.contains("CREATECOMPLETE")
5114 || status_upper.contains("CREATE_COMPLETE")
5115 {
5116 "✅ Create complete"
5117 } else {
5118 &app2.status
5119 };
5120 assert_eq!(formatted, "✅ Create complete");
5121 }
5122
5123 #[test]
5124 fn test_pagination_shows_1_when_empty() {
5125 let result = render_pagination_text(0, 0);
5126 assert_eq!(result, "[1]");
5127 }
5128
5129 #[test]
5130 fn test_pagination_shows_current_page() {
5131 let result = render_pagination_text(0, 3);
5132 assert_eq!(result, "[1] 2 3");
5133
5134 let result = render_pagination_text(1, 3);
5135 assert_eq!(result, "1 [2] 3");
5136 }
5137
5138 #[test]
5139 fn test_cloudformation_section_heights_match_content() {
5140 let overview_fields = 14;
5143 let overview_height = overview_fields + 2;
5144 assert_eq!(overview_height, 16);
5145
5146 let tags_empty_lines = 4;
5148 let tags_empty_height = tags_empty_lines + 2;
5149 assert_eq!(tags_empty_height, 6);
5150
5151 let policy_empty_lines = 5;
5153 let policy_empty_height = policy_empty_lines + 2;
5154 assert_eq!(policy_empty_height, 7);
5155
5156 let rollback_empty_lines = 6;
5158 let rollback_empty_height = rollback_empty_lines + 2;
5159 assert_eq!(rollback_empty_height, 8);
5160
5161 let notifications_empty_lines = 4;
5163 let notifications_empty_height = notifications_empty_lines + 2;
5164 assert_eq!(notifications_empty_height, 6);
5165 }
5166
5167 #[test]
5168 fn test_log_groups_uses_table_state() {
5169 let mut app = test_app_no_region();
5170 app.current_service = Service::CloudWatchLogGroups;
5171
5172 assert_eq!(app.log_groups_state.log_groups.items.len(), 0);
5174 assert_eq!(app.log_groups_state.log_groups.selected, 0);
5175 assert_eq!(app.log_groups_state.log_groups.filter, "");
5176 assert_eq!(
5177 app.log_groups_state.log_groups.page_size,
5178 crate::common::PageSize::Fifty
5179 );
5180 }
5181
5182 #[test]
5183 fn test_log_groups_filter_and_pagination() {
5184 let mut app = test_app_no_region();
5185 app.current_service = Service::CloudWatchLogGroups;
5186
5187 app.log_groups_state.log_groups.items = vec![
5189 rusticity_core::LogGroup {
5190 name: "/aws/lambda/function1".to_string(),
5191 creation_time: None,
5192 stored_bytes: Some(1024),
5193 retention_days: None,
5194 log_class: None,
5195 arn: None,
5196 },
5197 rusticity_core::LogGroup {
5198 name: "/aws/lambda/function2".to_string(),
5199 creation_time: None,
5200 stored_bytes: Some(2048),
5201 retention_days: None,
5202 log_class: None,
5203 arn: None,
5204 },
5205 rusticity_core::LogGroup {
5206 name: "/aws/ecs/service1".to_string(),
5207 creation_time: None,
5208 stored_bytes: Some(4096),
5209 retention_days: None,
5210 log_class: None,
5211 arn: None,
5212 },
5213 ];
5214
5215 app.log_groups_state.log_groups.filter = "lambda".to_string();
5217 let filtered = filtered_log_groups(&app);
5218 assert_eq!(filtered.len(), 2);
5219
5220 let page_size = app.log_groups_state.log_groups.page_size.value();
5222 assert_eq!(page_size, 50);
5223 }
5224
5225 #[test]
5226 fn test_log_groups_expandable_indicators() {
5227 let group = rusticity_core::LogGroup {
5228 name: "/aws/lambda/test".to_string(),
5229 creation_time: None,
5230 stored_bytes: Some(1024),
5231 retention_days: None,
5232 log_class: None,
5233 arn: None,
5234 };
5235
5236 let collapsed = crate::ui::table::format_expandable(&group.name, false);
5238 assert!(collapsed.starts_with("► "));
5239 assert!(collapsed.contains("/aws/lambda/test"));
5240
5241 let expanded = crate::ui::table::format_expandable(&group.name, true);
5243 assert!(expanded.starts_with("▼ "));
5244 assert!(expanded.contains("/aws/lambda/test"));
5245 }
5246
5247 #[test]
5248 fn test_log_groups_visual_boundaries() {
5249 assert_eq!(crate::ui::table::CURSOR_COLLAPSED, "►");
5251 assert_eq!(crate::ui::table::CURSOR_EXPANDED, "▼");
5252
5253 let continuation = "│ ";
5256 let last_line = "╰ ";
5257
5258 assert_eq!(continuation, "│ ");
5259 assert_eq!(last_line, "╰ ");
5260 }
5261
5262 #[test]
5263 fn test_log_groups_right_arrow_expands() {
5264 let mut app = test_app();
5265 app.current_service = Service::CloudWatchLogGroups;
5266 app.service_selected = true;
5267 app.view_mode = ViewMode::List;
5268
5269 app.log_groups_state.log_groups.items = vec![rusticity_core::LogGroup {
5270 name: "/aws/lambda/test".to_string(),
5271 creation_time: None,
5272 stored_bytes: Some(1024),
5273 retention_days: None,
5274 log_class: None,
5275 arn: None,
5276 }];
5277 app.log_groups_state.log_groups.selected = 0;
5278
5279 assert_eq!(app.log_groups_state.log_groups.expanded_item, None);
5280
5281 app.handle_action(Action::NextPane);
5283 assert_eq!(app.log_groups_state.log_groups.expanded_item, Some(0));
5284
5285 app.handle_action(Action::PrevPane);
5287 assert_eq!(app.log_groups_state.log_groups.expanded_item, None);
5288 }
5289
5290 #[test]
5291 fn test_log_streams_right_arrow_expands() {
5292 let mut app = test_app();
5293 app.current_service = Service::CloudWatchLogGroups;
5294 app.service_selected = true;
5295 app.view_mode = ViewMode::Detail;
5296
5297 app.log_groups_state.log_streams = vec![rusticity_core::LogStream {
5298 name: "stream-1".to_string(),
5299 creation_time: None,
5300 last_event_time: None,
5301 }];
5302 app.log_groups_state.selected_stream = 0;
5303
5304 assert_eq!(app.log_groups_state.expanded_stream, None);
5305
5306 app.handle_action(Action::NextPane);
5308 assert_eq!(app.log_groups_state.expanded_stream, Some(0));
5309
5310 app.handle_action(Action::PrevPane);
5312 assert_eq!(app.log_groups_state.expanded_stream, None);
5313 }
5314
5315 #[test]
5316 fn test_log_events_border_style_no_double_border() {
5317 let mut app = test_app();
5320 app.current_service = Service::CloudWatchLogGroups;
5321 app.service_selected = true;
5322 app.view_mode = ViewMode::Events;
5323
5324 assert_eq!(app.view_mode, ViewMode::Events);
5327 }
5328
5329 #[test]
5330 fn test_log_group_detail_border_style_no_double_border() {
5331 let mut app = test_app();
5333 app.current_service = Service::CloudWatchLogGroups;
5334 app.service_selected = true;
5335 app.view_mode = ViewMode::Detail;
5336
5337 assert_eq!(app.view_mode, ViewMode::Detail);
5339 }
5340
5341 #[test]
5342 fn test_expansion_uses_intermediate_field_indicator() {
5343 let intermediate = "├ ";
5351 let continuation = "│ ";
5352 let last = "╰ ";
5353
5354 assert_eq!(intermediate, "├ ");
5355 assert_eq!(continuation, "│ ");
5356 assert_eq!(last, "╰ ");
5357 }
5358
5359 #[test]
5360 fn test_log_streams_expansion_renders() {
5361 let mut app = test_app();
5362 app.current_service = Service::CloudWatchLogGroups;
5363 app.service_selected = true;
5364 app.view_mode = ViewMode::Detail;
5365
5366 app.log_groups_state.log_streams = vec![rusticity_core::LogStream {
5367 name: "test-stream".to_string(),
5368 creation_time: None,
5369 last_event_time: None,
5370 }];
5371 app.log_groups_state.selected_stream = 0;
5372 app.log_groups_state.expanded_stream = Some(0);
5373
5374 assert_eq!(app.log_groups_state.expanded_stream, Some(0));
5376
5377 assert_eq!(app.log_groups_state.log_streams.len(), 1);
5379 assert_eq!(app.log_groups_state.log_streams[0].name, "test-stream");
5380 }
5381
5382 #[test]
5383 fn test_log_streams_filter_layout_single_line() {
5384 let _app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
5387
5388 let expected_filter_height = 3;
5391 assert_eq!(expected_filter_height, 3);
5392 }
5393
5394 #[test]
5395 fn test_table_navigation_at_page_boundary() {
5396 let mut app = test_app();
5397 app.current_service = Service::CloudWatchLogGroups;
5398 app.service_selected = true;
5399 app.view_mode = ViewMode::List;
5400 app.mode = Mode::Normal;
5401
5402 for i in 0..100 {
5404 app.log_groups_state
5405 .log_groups
5406 .items
5407 .push(rusticity_core::LogGroup {
5408 name: format!("/aws/lambda/function{}", i),
5409 creation_time: None,
5410 stored_bytes: Some(1024),
5411 retention_days: None,
5412 log_class: None,
5413 arn: None,
5414 });
5415 }
5416
5417 app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
5419
5420 app.log_groups_state.log_groups.selected = 49;
5422
5423 app.handle_action(Action::NextItem);
5425 assert_eq!(app.log_groups_state.log_groups.selected, 50);
5426
5427 app.handle_action(Action::PrevItem);
5429 assert_eq!(app.log_groups_state.log_groups.selected, 49);
5430
5431 app.handle_action(Action::NextItem);
5433 assert_eq!(app.log_groups_state.log_groups.selected, 50);
5434
5435 app.handle_action(Action::PrevItem);
5437 assert_eq!(app.log_groups_state.log_groups.selected, 49);
5438 }
5439
5440 #[test]
5441 fn test_table_navigation_at_end() {
5442 let mut app = test_app();
5443 app.current_service = Service::CloudWatchLogGroups;
5444 app.service_selected = true;
5445 app.view_mode = ViewMode::List;
5446 app.mode = Mode::Normal;
5447
5448 for i in 0..100 {
5450 app.log_groups_state
5451 .log_groups
5452 .items
5453 .push(rusticity_core::LogGroup {
5454 name: format!("/aws/lambda/function{}", i),
5455 creation_time: None,
5456 stored_bytes: Some(1024),
5457 retention_days: None,
5458 log_class: None,
5459 arn: None,
5460 });
5461 }
5462
5463 app.log_groups_state.log_groups.selected = 99;
5465
5466 app.handle_action(Action::NextItem);
5468 assert_eq!(app.log_groups_state.log_groups.selected, 99);
5469
5470 app.handle_action(Action::PrevItem);
5472 assert_eq!(app.log_groups_state.log_groups.selected, 98);
5473 }
5474
5475 #[test]
5476 fn test_table_viewport_scrolling() {
5477 let mut app = test_app();
5478 app.current_service = Service::CloudWatchLogGroups;
5479 app.service_selected = true;
5480 app.view_mode = ViewMode::List;
5481 app.mode = Mode::Normal;
5482
5483 for i in 0..100 {
5485 app.log_groups_state
5486 .log_groups
5487 .items
5488 .push(rusticity_core::LogGroup {
5489 name: format!("/aws/lambda/function{}", i),
5490 creation_time: None,
5491 stored_bytes: Some(1024),
5492 retention_days: None,
5493 log_class: None,
5494 arn: None,
5495 });
5496 }
5497
5498 app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
5500
5501 app.log_groups_state.log_groups.selected = 49;
5503 app.log_groups_state.log_groups.scroll_offset = 0;
5504
5505 app.handle_action(Action::NextItem);
5507 assert_eq!(app.log_groups_state.log_groups.selected, 50);
5508 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); app.handle_action(Action::PrevItem);
5512 assert_eq!(app.log_groups_state.log_groups.selected, 49);
5513 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); app.handle_action(Action::PrevItem);
5517 assert_eq!(app.log_groups_state.log_groups.selected, 48);
5518 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); for _ in 0..47 {
5522 app.handle_action(Action::PrevItem);
5523 }
5524 assert_eq!(app.log_groups_state.log_groups.selected, 1);
5525 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); app.handle_action(Action::PrevItem);
5529 assert_eq!(app.log_groups_state.log_groups.selected, 0);
5530 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 0); }
5532
5533 #[test]
5534 fn test_table_up_from_last_row() {
5535 let mut app = test_app();
5536 app.current_service = Service::CloudWatchLogGroups;
5537 app.service_selected = true;
5538 app.view_mode = ViewMode::List;
5539 app.mode = Mode::Normal;
5540
5541 for i in 0..100 {
5543 app.log_groups_state
5544 .log_groups
5545 .items
5546 .push(rusticity_core::LogGroup {
5547 name: format!("/aws/lambda/function{}", i),
5548 creation_time: None,
5549 stored_bytes: Some(1024),
5550 retention_days: None,
5551 log_class: None,
5552 arn: None,
5553 });
5554 }
5555
5556 app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
5558
5559 app.log_groups_state.log_groups.selected = 99;
5561 app.log_groups_state.log_groups.scroll_offset = 50; app.handle_action(Action::PrevItem);
5565 assert_eq!(app.log_groups_state.log_groups.selected, 98);
5566 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 50); app.handle_action(Action::PrevItem);
5570 assert_eq!(app.log_groups_state.log_groups.selected, 97);
5571 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 50); }
5573
5574 #[test]
5575 fn test_table_up_from_last_visible_row() {
5576 let mut app = test_app();
5577 app.current_service = Service::CloudWatchLogGroups;
5578 app.service_selected = true;
5579 app.view_mode = ViewMode::List;
5580 app.mode = Mode::Normal;
5581
5582 for i in 0..100 {
5584 app.log_groups_state
5585 .log_groups
5586 .items
5587 .push(rusticity_core::LogGroup {
5588 name: format!("/aws/lambda/function{}", i),
5589 creation_time: None,
5590 stored_bytes: Some(1024),
5591 retention_days: None,
5592 log_class: None,
5593 arn: None,
5594 });
5595 }
5596
5597 app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
5599
5600 app.log_groups_state.log_groups.selected = 49;
5602 app.log_groups_state.log_groups.scroll_offset = 0;
5603 app.handle_action(Action::NextItem);
5604
5605 assert_eq!(app.log_groups_state.log_groups.selected, 50);
5607 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1);
5608
5609 app.handle_action(Action::PrevItem);
5612 assert_eq!(
5613 app.log_groups_state.log_groups.selected, 49,
5614 "Selection should move to 49"
5615 );
5616 assert_eq!(
5617 app.log_groups_state.log_groups.scroll_offset, 1,
5618 "Should NOT scroll up"
5619 );
5620 }
5621
5622 #[test]
5623 fn test_cloudformation_up_from_last_visible_row() {
5624 let mut app = test_app();
5625 app.current_service = Service::CloudFormationStacks;
5626 app.service_selected = true;
5627 app.mode = Mode::Normal;
5628
5629 for i in 0..100 {
5631 app.cfn_state.table.items.push(crate::cfn::Stack {
5632 name: format!("Stack{}", i),
5633 stack_id: format!("id{}", i),
5634 status: "CREATE_COMPLETE".to_string(),
5635 created_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5636 updated_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5637 deleted_time: String::new(),
5638 description: "Test".to_string(),
5639 drift_status: "NOT_CHECKED".to_string(),
5640 last_drift_check_time: "-".to_string(),
5641 status_reason: String::new(),
5642 detailed_status: "CREATE_COMPLETE".to_string(),
5643 root_stack: String::new(),
5644 parent_stack: String::new(),
5645 termination_protection: false,
5646 iam_role: String::new(),
5647 tags: Vec::new(),
5648 stack_policy: String::new(),
5649 rollback_monitoring_time: String::new(),
5650 rollback_alarms: Vec::new(),
5651 notification_arns: Vec::new(),
5652 });
5653 }
5654
5655 app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
5657
5658 app.cfn_state.table.selected = 49;
5660 app.cfn_state.table.scroll_offset = 0;
5661 app.handle_action(Action::NextItem);
5662
5663 assert_eq!(app.cfn_state.table.selected, 50);
5665 assert_eq!(app.cfn_state.table.scroll_offset, 1);
5666
5667 app.handle_action(Action::PrevItem);
5669 assert_eq!(
5670 app.cfn_state.table.selected, 49,
5671 "Selection should move to 49"
5672 );
5673 assert_eq!(
5674 app.cfn_state.table.scroll_offset, 1,
5675 "Should NOT scroll up - this is the bug!"
5676 );
5677 }
5678
5679 #[test]
5680 fn test_cloudformation_up_from_actual_last_row() {
5681 let mut app = test_app();
5682 app.current_service = Service::CloudFormationStacks;
5683 app.service_selected = true;
5684 app.mode = Mode::Normal;
5685
5686 for i in 0..88 {
5688 app.cfn_state.table.items.push(crate::cfn::Stack {
5689 name: format!("Stack{}", i),
5690 stack_id: format!("id{}", i),
5691 status: "CREATE_COMPLETE".to_string(),
5692 created_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5693 updated_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5694 deleted_time: String::new(),
5695 description: "Test".to_string(),
5696 drift_status: "NOT_CHECKED".to_string(),
5697 last_drift_check_time: "-".to_string(),
5698 status_reason: String::new(),
5699 detailed_status: "CREATE_COMPLETE".to_string(),
5700 root_stack: String::new(),
5701 parent_stack: String::new(),
5702 termination_protection: false,
5703 iam_role: String::new(),
5704 tags: Vec::new(),
5705 stack_policy: String::new(),
5706 rollback_monitoring_time: String::new(),
5707 rollback_alarms: Vec::new(),
5708 notification_arns: Vec::new(),
5709 });
5710 }
5711
5712 app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
5714
5715 app.cfn_state.table.selected = 87;
5718 app.cfn_state.table.scroll_offset = 38; app.handle_action(Action::PrevItem);
5722 assert_eq!(
5723 app.cfn_state.table.selected, 86,
5724 "Selection should move to 86"
5725 );
5726 assert_eq!(
5727 app.cfn_state.table.scroll_offset, 38,
5728 "Should NOT scroll - scroll_offset should stay at 38"
5729 );
5730 }
5731
5732 #[test]
5733 fn test_iam_users_default_columns() {
5734 let app = test_app();
5735 assert_eq!(app.iam_user_visible_column_ids.len(), 11);
5736 assert!(app
5737 .iam_user_visible_column_ids
5738 .contains(&"column.iam.user.user_name"));
5739 assert!(app
5740 .iam_user_visible_column_ids
5741 .contains(&"column.iam.user.path"));
5742 assert!(app
5743 .iam_user_visible_column_ids
5744 .contains(&"column.iam.user.arn"));
5745 }
5746
5747 #[test]
5748 fn test_iam_users_all_columns() {
5749 let app = test_app();
5750 assert_eq!(app.iam_user_column_ids.len(), 14);
5751 assert!(app
5752 .iam_user_column_ids
5753 .contains(&"column.iam.user.creation_time"));
5754 assert!(app
5755 .iam_user_column_ids
5756 .contains(&"column.iam.user.console_access"));
5757 assert!(app
5758 .iam_user_column_ids
5759 .contains(&"column.iam.user.signing_certs"));
5760 }
5761
5762 #[test]
5763 fn test_iam_users_filter() {
5764 let mut app = test_app();
5765 app.current_service = Service::IamUsers;
5766
5767 app.iam_state.users.items = vec![
5769 crate::iam::IamUser {
5770 user_name: "alice".to_string(),
5771 path: "/".to_string(),
5772 groups: "admins".to_string(),
5773 last_activity: "2024-01-01".to_string(),
5774 mfa: "Enabled".to_string(),
5775 password_age: "30 days".to_string(),
5776 console_last_sign_in: "2024-01-01".to_string(),
5777 access_key_id: "AKIA...".to_string(),
5778 active_key_age: "60 days".to_string(),
5779 access_key_last_used: "2024-01-01".to_string(),
5780 arn: "arn:aws:iam::123456789012:user/alice".to_string(),
5781 creation_time: "2023-01-01".to_string(),
5782 console_access: "Enabled".to_string(),
5783 signing_certs: "0".to_string(),
5784 },
5785 crate::iam::IamUser {
5786 user_name: "bob".to_string(),
5787 path: "/".to_string(),
5788 groups: "developers".to_string(),
5789 last_activity: "2024-01-02".to_string(),
5790 mfa: "Disabled".to_string(),
5791 password_age: "45 days".to_string(),
5792 console_last_sign_in: "2024-01-02".to_string(),
5793 access_key_id: "AKIA...".to_string(),
5794 active_key_age: "90 days".to_string(),
5795 access_key_last_used: "2024-01-02".to_string(),
5796 arn: "arn:aws:iam::123456789012:user/bob".to_string(),
5797 creation_time: "2023-02-01".to_string(),
5798 console_access: "Enabled".to_string(),
5799 signing_certs: "1".to_string(),
5800 },
5801 ];
5802
5803 let filtered = crate::ui::iam::filtered_iam_users(&app);
5805 assert_eq!(filtered.len(), 2);
5806
5807 app.iam_state.users.filter = "alice".to_string();
5809 let filtered = crate::ui::iam::filtered_iam_users(&app);
5810 assert_eq!(filtered.len(), 1);
5811 assert_eq!(filtered[0].user_name, "alice");
5812
5813 app.iam_state.users.filter = "BOB".to_string();
5815 let filtered = crate::ui::iam::filtered_iam_users(&app);
5816 assert_eq!(filtered.len(), 1);
5817 assert_eq!(filtered[0].user_name, "bob");
5818 }
5819
5820 #[test]
5821 fn test_iam_users_pagination() {
5822 let mut app = test_app();
5823 app.current_service = Service::IamUsers;
5824
5825 for i in 0..30 {
5827 app.iam_state.users.items.push(crate::iam::IamUser {
5828 user_name: format!("user{}", i),
5829 path: "/".to_string(),
5830 groups: String::new(),
5831 last_activity: "-".to_string(),
5832 mfa: "Disabled".to_string(),
5833 password_age: "-".to_string(),
5834 console_last_sign_in: "-".to_string(),
5835 access_key_id: "-".to_string(),
5836 active_key_age: "-".to_string(),
5837 access_key_last_used: "-".to_string(),
5838 arn: format!("arn:aws:iam::123456789012:user/user{}", i),
5839 creation_time: "2023-01-01".to_string(),
5840 console_access: "Disabled".to_string(),
5841 signing_certs: "0".to_string(),
5842 });
5843 }
5844
5845 app.iam_state.users.page_size = crate::common::PageSize::TwentyFive;
5847
5848 let filtered = crate::ui::iam::filtered_iam_users(&app);
5849 assert_eq!(filtered.len(), 30);
5850
5851 let page_size = app.iam_state.users.page_size.value();
5853 assert_eq!(page_size, 25);
5854 }
5855
5856 #[test]
5857 fn test_iam_users_expansion() {
5858 let mut app = test_app();
5859 app.current_service = Service::IamUsers;
5860 app.service_selected = true;
5861 app.mode = Mode::Normal;
5862
5863 app.iam_state.users.items = vec![crate::iam::IamUser {
5864 user_name: "testuser".to_string(),
5865 path: "/admin/".to_string(),
5866 groups: "admins,developers".to_string(),
5867 last_activity: "2024-01-01".to_string(),
5868 mfa: "Enabled".to_string(),
5869 password_age: "30 days".to_string(),
5870 console_last_sign_in: "2024-01-01 10:00:00".to_string(),
5871 access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
5872 active_key_age: "60 days".to_string(),
5873 access_key_last_used: "2024-01-01 09:00:00".to_string(),
5874 arn: "arn:aws:iam::123456789012:user/admin/testuser".to_string(),
5875 creation_time: "2023-01-01 00:00:00".to_string(),
5876 console_access: "Enabled".to_string(),
5877 signing_certs: "2".to_string(),
5878 }];
5879
5880 app.handle_action(Action::NextPane);
5882 assert_eq!(app.iam_state.users.expanded_item, Some(0));
5883
5884 app.handle_action(Action::PrevPane);
5886 assert_eq!(app.iam_state.users.expanded_item, None);
5887 }
5888
5889 #[test]
5890 fn test_iam_users_in_service_picker() {
5891 let app = test_app();
5892 assert!(app.service_picker.services.contains(&"IAM > Users"));
5893 }
5894
5895 #[test]
5896 fn test_iam_users_service_selection() {
5897 let mut app = test_app();
5898 app.mode = Mode::ServicePicker;
5899 let filtered = app.filtered_services();
5900 let selected_idx = filtered.iter().position(|&s| s == "IAM > Users").unwrap();
5901 app.service_picker.selected = selected_idx;
5902
5903 app.handle_action(Action::Select);
5904
5905 assert_eq!(app.current_service, Service::IamUsers);
5906 assert!(app.service_selected);
5907 assert_eq!(app.tabs.len(), 1);
5908 assert_eq!(app.tabs[0].service, Service::IamUsers);
5909 assert_eq!(app.tabs[0].title, "IAM > Users");
5910 }
5911
5912 #[test]
5913 fn test_format_duration_seconds() {
5914 assert_eq!(format_duration(1), "1 second");
5915 assert_eq!(format_duration(30), "30 seconds");
5916 }
5917
5918 #[test]
5919 fn test_format_duration_minutes() {
5920 assert_eq!(format_duration(60), "1 minute");
5921 assert_eq!(format_duration(120), "2 minutes");
5922 assert_eq!(format_duration(3600 - 1), "59 minutes");
5923 }
5924
5925 #[test]
5926 fn test_format_duration_hours() {
5927 assert_eq!(format_duration(3600), "1 hour");
5928 assert_eq!(format_duration(7200), "2 hours");
5929 assert_eq!(format_duration(3600 + 1800), "1 hour 30 minutes");
5930 assert_eq!(format_duration(7200 + 60), "2 hours 1 minute");
5931 }
5932
5933 #[test]
5934 fn test_format_duration_days() {
5935 assert_eq!(format_duration(86400), "1 day");
5936 assert_eq!(format_duration(172800), "2 days");
5937 assert_eq!(format_duration(86400 + 3600), "1 day 1 hour");
5938 assert_eq!(format_duration(172800 + 7200), "2 days 2 hours");
5939 }
5940
5941 #[test]
5942 fn test_format_duration_weeks() {
5943 assert_eq!(format_duration(604800), "1 week");
5944 assert_eq!(format_duration(1209600), "2 weeks");
5945 assert_eq!(format_duration(604800 + 86400), "1 week 1 day");
5946 assert_eq!(format_duration(1209600 + 172800), "2 weeks 2 days");
5947 }
5948
5949 #[test]
5950 fn test_format_duration_years() {
5951 assert_eq!(format_duration(31536000), "1 year");
5952 assert_eq!(format_duration(63072000), "2 years");
5953 assert_eq!(format_duration(31536000 + 604800), "1 year 1 week");
5954 assert_eq!(format_duration(63072000 + 1209600), "2 years 2 weeks");
5955 }
5956
5957 #[test]
5958 fn test_tab_style_selected() {
5959 let style = tab_style(true);
5960 assert_eq!(style, highlight());
5961 }
5962
5963 #[test]
5964 fn test_tab_style_not_selected() {
5965 let style = tab_style(false);
5966 assert_eq!(style, Style::default());
5967 }
5968
5969 #[test]
5970 fn test_render_tab_spans_single_tab() {
5971 let tabs = [("Tab1", true)];
5972 let spans = render_tab_spans(&tabs);
5973 assert_eq!(spans.len(), 1);
5974 assert_eq!(spans[0].content, "Tab1");
5975 assert_eq!(spans[0].style, service_tab_style(true));
5976 }
5977
5978 #[test]
5979 fn test_render_tab_spans_multiple_tabs() {
5980 let tabs = [("Tab1", true), ("Tab2", false), ("Tab3", false)];
5981 let spans = render_tab_spans(&tabs);
5982 assert_eq!(spans.len(), 5); assert_eq!(spans[0].content, "Tab1");
5984 assert_eq!(spans[0].style, service_tab_style(true));
5985 assert_eq!(spans[1].content, " ⋮ ");
5986 assert_eq!(spans[2].content, "Tab2");
5987 assert_eq!(spans[2].style, Style::default());
5988 assert_eq!(spans[3].content, " ⋮ ");
5989 assert_eq!(spans[4].content, "Tab3");
5990 assert_eq!(spans[4].style, Style::default());
5991 }
5992
5993 #[test]
5994 fn test_render_tab_spans_no_separator_for_first() {
5995 let tabs = [("First", false), ("Second", true)];
5996 let spans = render_tab_spans(&tabs);
5997 assert_eq!(spans.len(), 3); assert_eq!(spans[0].content, "First");
5999 assert_eq!(spans[1].content, " ⋮ ");
6000 assert_eq!(spans[2].content, "Second");
6001 assert_eq!(spans[2].style, service_tab_style(true));
6002 }
6003
6004 #[test]
6005 fn test_calculate_dynamic_height_empty() {
6006 let fields: Vec<Line> = vec![];
6007 assert_eq!(calculate_dynamic_height(&fields, 100), 0);
6008 }
6009
6010 #[test]
6011 fn test_calculate_dynamic_height_single_column() {
6012 let fields = vec![
6013 Line::from("Field 1"),
6014 Line::from("Field 2"),
6015 Line::from("Field 3"),
6016 ];
6017 assert_eq!(calculate_dynamic_height(&fields, 30), 1);
6019 }
6020
6021 #[test]
6022 fn test_calculate_dynamic_height_two_columns() {
6023 let fields = vec![
6024 Line::from("Field 1"),
6025 Line::from("Field 2"),
6026 Line::from("Field 3"),
6027 Line::from("Field 4"),
6028 Line::from("Field 5"),
6029 ];
6030 assert_eq!(calculate_dynamic_height(&fields, 20), 3);
6032 }
6033
6034 #[test]
6035 fn test_calculate_dynamic_height_three_columns() {
6036 let fields = vec![
6037 Line::from("F1"),
6038 Line::from("F2"),
6039 Line::from("F3"),
6040 Line::from("F4"),
6041 Line::from("F5"),
6042 Line::from("F6"),
6043 Line::from("F7"),
6044 Line::from("F8"),
6045 Line::from("F9"),
6046 Line::from("F10"),
6047 ];
6048 let result = calculate_dynamic_height(&fields, 20);
6051 assert_eq!(result, 4);
6055 }
6056
6057 #[test]
6058 fn test_calculate_dynamic_height_even_distribution() {
6059 let fields = vec![
6060 Line::from("A"),
6061 Line::from("B"),
6062 Line::from("C"),
6063 Line::from("D"),
6064 ];
6065 assert_eq!(calculate_dynamic_height(&fields, 100), 2);
6068 }
6069
6070 #[test]
6071 fn test_ec2_tags_preferences_shows_tag_columns() {
6072 let mut app = crate::app::App::new_without_client("default".to_string(), None);
6073 app.current_service = crate::app::Service::Ec2Instances;
6074 app.ec2_state.current_instance = Some("i-123".to_string());
6075 app.ec2_state.detail_tab = ec2::DetailTab::Tags;
6076 app.mode = crate::keymap::Mode::ColumnSelector;
6077
6078 use ratatui::backend::TestBackend;
6080 use ratatui::Terminal;
6081 let backend = TestBackend::new(80, 24);
6082 let mut terminal = Terminal::new(backend).unwrap();
6083
6084 terminal
6085 .draw(|f| {
6086 render(f, &app);
6087 })
6088 .unwrap();
6089
6090 let buffer = terminal.backend().buffer().clone();
6091 let content = buffer
6092 .content()
6093 .iter()
6094 .map(|c| c.symbol())
6095 .collect::<String>();
6096
6097 assert!(content.contains("Key"));
6099 assert!(content.contains("Value"));
6100 assert!(!content.contains("Log stream"));
6101 }
6102
6103 #[test]
6104 fn test_cloudwatch_detail_preferences_shows_stream_columns() {
6105 let mut app = crate::app::App::new_without_client("default".to_string(), None);
6106 app.current_service = crate::app::Service::CloudWatchLogGroups;
6107 app.view_mode = crate::app::ViewMode::Detail;
6108 app.mode = crate::keymap::Mode::ColumnSelector;
6109
6110 use ratatui::backend::TestBackend;
6111 use ratatui::Terminal;
6112 let backend = TestBackend::new(80, 24);
6113 let mut terminal = Terminal::new(backend).unwrap();
6114
6115 terminal
6116 .draw(|f| {
6117 render(f, &app);
6118 })
6119 .unwrap();
6120
6121 let buffer = terminal.backend().buffer().clone();
6122 let content = buffer
6123 .content()
6124 .iter()
6125 .map(|c| c.symbol())
6126 .collect::<String>();
6127
6128 assert!(content.contains("Log stream"));
6130 }
6131
6132 #[test]
6133 fn test_ec2_instances_preferences_shows_instance_columns() {
6134 let mut app = crate::app::App::new_without_client("default".to_string(), None);
6135 app.current_service = crate::app::Service::Ec2Instances;
6136 app.mode = crate::keymap::Mode::ColumnSelector;
6137
6138 use ratatui::backend::TestBackend;
6139 use ratatui::Terminal;
6140 let backend = TestBackend::new(120, 40);
6141 let mut terminal = Terminal::new(backend).unwrap();
6142
6143 terminal
6144 .draw(|f| {
6145 render(f, &app);
6146 })
6147 .unwrap();
6148
6149 let buffer = terminal.backend().buffer().clone();
6150 let content = buffer
6151 .content()
6152 .iter()
6153 .map(|c| c.symbol())
6154 .collect::<String>();
6155
6156 assert!(content.contains("Columns"));
6158 assert!(!content.contains("Log stream"));
6159 }
6160
6161 #[test]
6162 fn test_section_header_has_leading_dash() {
6163 let line = section_header("Default encryption", 50);
6164 let text = line
6165 .spans
6166 .iter()
6167 .map(|s| s.content.as_ref())
6168 .collect::<String>();
6169
6170 assert!(text.starts_with("─ "));
6172 assert!(text.contains("Default encryption"));
6174 assert!(text.ends_with('─'));
6176 }
6177
6178 #[test]
6179 fn test_section_header_width_calculation() {
6180 let width = 60;
6181 let line = section_header("Test Section", width);
6182 let text = line
6183 .spans
6184 .iter()
6185 .map(|s| s.content.as_ref())
6186 .collect::<String>();
6187
6188 assert_eq!(text.chars().count(), width as usize);
6190 assert!(text.starts_with("─ Test Section "));
6192 }
6193}