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