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