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