Skip to main content

rusticity_term/ui/
mod.rs

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    // Strip trailing ": " or ":" so callers that already include it don't double up
92    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
102/// Like `labeled_field` but with a colored value span.
103pub 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
116/// Calculate the height needed for a block containing lines (lines + 2 for borders)
117pub fn block_height(lines: &[Line]) -> u16 {
118    lines.len() as u16 + 2
119}
120
121/// Calculate the height needed for a block with a given number of lines (lines + 2 for borders)
122pub 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    // Format: "─ Section Name ───────────────────"
129    // dash + space + text + space + dashes = width
130    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
168// Common UI constants
169pub const SEARCH_ICON: &str = "─ 🔍 ─";
170pub const PREFERENCES_TITLE: &str = "Preferences";
171
172// Filter
173pub 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
191// Common style helpers
192pub 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
310// Common UI constants
311
312// Common style helpers
313pub 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
333// Block helpers
334pub 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
342/// Renders fields in dynamic columns based on available width
343/// Returns the height needed for the content
344pub 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    // Calculate max width needed per field
352    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    // Determine how many columns fit (max 3)
367    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    // Distribute fields: first column gets most, others get same or fewer
373    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    // Calculate max rows
398    let max_rows = columns.iter().map(|c| c.len()).max().unwrap_or(1) as u16;
399
400    // Create layout
401    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    // Render each column — no wrapping to avoid ╰ continuation glyphs on border rows
411    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
420/// Calculates the height needed for fields with dynamic columns (without rendering)
421pub 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
450// Render a summary section with labeled fields
451pub 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
464// Render tabs with selection highlighting
465pub 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
570// Helper to render a section header
571fn 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
584// Helper to render a radio button item
585fn 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
598// Helper to render page size options
599fn 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    // Always show tabs row (with profile info), optionally show top bar for breadcrumbs
624    let has_tabs = !app.tabs.is_empty();
625    let show_breadcrumbs = has_tabs && app.service_selected && {
626        // Only show breadcrumbs if we're deeper than the root level
627        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), // Tabs row (profile info + tabs)
638                Constraint::Length(1), // Top bar (breadcrumbs)
639                Constraint::Min(0),    // Content
640                Constraint::Length(1), // Bottom bar
641            ])
642            .split(area)
643    } else {
644        Layout::default()
645            .direction(Direction::Vertical)
646            .constraints([
647                Constraint::Length(2), // Tabs row (profile info + tabs)
648                Constraint::Min(0),    // Content
649                Constraint::Length(1), // Bottom bar
650            ])
651            .split(area)
652    };
653
654    // Always render tabs row (shows profile info)
655    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        // Empty screen with message
666        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        // SpaceMenu with no service selected - just render bottom bar
686        render_bottom_bar(frame, app, chunks[bottom_idx]);
687    }
688
689    // Render modals on top
690    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    // Split into 2 lines: profile info on top, tabs below
708    let chunks = Layout::default()
709        .direction(Direction::Vertical)
710        .constraints([Constraint::Length(1), Constraint::Length(1)])
711        .split(area);
712
713    // Profile info line (highlighted)
714    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    // Tabs line
792    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    // For S3 with prefix, highlight the last part (prefix)
808    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                // Last part (prefix) - highlight in cyan
820                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        // Columns section
922        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        // View As section
936        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        // Page Size section
958        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        // Wrap Lines section
965        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            // Resources table in detail view - show resource columns
980            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            // Main events table
999            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            // Tags tab columns
1052            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            // Log streams tab columns
1068            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            // API list view
1121            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            // Routes/Resources detail view
1142            use crate::apig::route::Column as RouteColumn;
1143            use crate::ui::apig::ApiDetailTab;
1144
1145            if app.apig_state.detail_tab == ApiDetailTab::Routes {
1146                // Check if REST API (shows resources) or HTTP/WebSocket (shows routes)
1147                if api.protocol_type.to_uppercase() == "REST" {
1148                    // REST API - show resource columns
1149                    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                                // First column (Resource) is always visible - show grayed out toggle
1159                                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                    // HTTP/WebSocket API - show route columns
1184                    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                                // First column (Route) is always visible - show grayed out toggle
1192                                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            // ECR images columns
1229            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            // ECR repository columns
1245            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            // Tags tab — Key/Value always visible (no toggling), but page size is configurable
1270            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)); // always visible
1277                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            // Access points tab — customizable columns + page size.
1293            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            // Network tab — customizable mount-target columns + page size.
1315            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            // File systems list — show file system columns
1335            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            // Fixed-column detail table (Tags / Updates / Backups): columns are
1368            // always visible; only the page size is configurable.
1369            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                    // Alias is always visible — show grayed-out disabled toggle
1428                    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            // Triggers tab - columns + page size
1512            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            // SNS Subscriptions tab - columns + page size
1539            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            // EventBridge Pipes tab - columns + page size
1569            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            // Tagging tab - columns + page size
1596            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            // Queue list - columns + page size
1621            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        // Show appropriate columns based on current tab
1656        if app.lambda_state.current_function.is_some()
1657            && app.lambda_state.detail_tab == LambdaDetailTab::Code
1658        {
1659            // Show layer columns for Code tab
1660            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        // Show different columns based on current view
1717        if app.lambda_application_state.current_application.is_some() {
1718            if app.lambda_application_state.detail_tab == ApplicationDetailTab::Overview {
1719                // Resources columns
1720                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                // Deployments columns
1738                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            // Application list columns
1757            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        // Check if we're in StackInfo tab (tags table)
1781        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            // Tags only have Key and Value columns
1789            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            // Stack list view
1913            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        // Template tab: no preferences
1933
1934        (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        // Show different sections based on the current tab in user detail view
1940        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        // Show different columns based on the current tab
2064        if app.iam_state.current_role.is_some() {
2065            match app.iam_state.role_tab {
2066                RoleTab::Permissions => {
2067                    // Show policy columns
2068                    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                    // Show tag columns (Key, Value)
2093                    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)); // Tags always show both columns
2100                        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                    // Other tabs don't have column preferences
2140                }
2141            }
2142        } else {
2143            // Role list view - show role columns
2144            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        // Fallback for unknown services
2193        (vec![], " Preferences ", 0)
2194    };
2195
2196    // Calculate popup size based on content
2197    let item_count = items.len();
2198
2199    // Width: based on content + padding
2200    let width = (max_text_len + 10).clamp(30, 100) as u16; // +10 for padding, min 30, max 100
2201
2202    // Height: fit all items if possible, otherwise use max available and show scrollbar
2203    let height = (item_count as u16 + 2).max(8); // +2 for borders, min 8
2204    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    // Check if scrollbar is needed
2209    let needs_scrollbar = height > max_height;
2210
2211    // Preferences should always have green border (active state)
2212    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    // Render scrollbar only if content doesn't fit
2233    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), // Header
2271            Constraint::Min(0),    // Error text (scrollable)
2272            Constraint::Length(2), // Help text
2273        ],
2274        inner,
2275    );
2276
2277    // Header
2278    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    // Scrollable error text with border
2284    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; // Account for borders + padding
2289            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    // Render scrollbar if needed
2314    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    // Help text
2339    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; // +2 for borders
2424    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    // Split into filter, list and preview
2508    let main_chunks = Layout::default()
2509        .direction(Direction::Vertical)
2510        .constraints([Constraint::Length(3), Constraint::Min(0)])
2511        .split(popup_area);
2512
2513    // Filter input
2514    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    // Tab list - use filtered tabs
2541    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    // Preview pane
2571    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 preview using the tab's service context
2585        // Note: This may show stale state if the tab's service differs from current_service
2586        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    // Find max line width
2722    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    // Content dimensions + borders + padding
2734    let content_width = max_width + 6; // +6 for borders and 1 char padding on each side
2735    let content_height = help_text.len() as u16 + 2; // +2 for borders
2736
2737    // Center the dialog
2738    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    // Filter input at top
2776    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    // Filtered list below
2787    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
2873// Render JSON content with syntax highlighting and scrollbar
2874/// Highlight a single YAML line (without the line-number gutter span).
2875/// Returns the colored spans for the trimmed content + indent.
2876fn 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    // Comment line
2890    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    // Document markers --- and ...
2899    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    // List item: starts with "- "
2908    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        // The rest may itself be a key: value
2920        spans.extend(highlight_yaml_value_or_key(rest));
2921        return spans;
2922    }
2923
2924    // key: value — split on first ':'
2925    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    // key: value  (colon followed by space, or colon at end of line)
2933    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            // Key — strip leading/trailing quotes if any
2938            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                // bare key with no value (mapping)
2947                return spans;
2948            }
2949
2950            // value after ": "
2951            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    // plain scalar (no key)
2959    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    // Inline comment — split on " #"
2967    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        // Block scalar indicator
2991        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        // YAML tags / anchors / aliases
2994        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
3009/// Render text with YAML syntax highlighting, line numbers, and scrollbar.
3010pub 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
3055/// Render template text with format-aware syntax highlighting.
3056/// Detects JSON (starts with `{` or `[`) and uses JSON highlighting;
3057/// everything else uses YAML highlighting.
3058pub 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            // Add line number gutter
3093            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
3148// Render a tags tab with description and table
3149pub 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
3156// Render a permissions tab with description and policies table
3157pub 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
3168// Render a last accessed tab with description, note, and table
3169pub 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        // Simulate the wrapping logic
3204        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        // First line should be marked as first (true)
3226        assert!(lines[0].1);
3227        // Continuation lines should be marked as continuation (false)
3228        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        // Should only have one line
3256        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        // Test that tabs are formatted with ⋮ separator
3264        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        // Should have 3 spans: Tab1, separator, Tab2
3286        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        // Current tab (index 2 in spans) should have yellow color
3324        assert_eq!(spans[2].style.fg, Some(Color::Yellow));
3325        assert!(spans[2].style.add_modifier.contains(Modifier::BOLD));
3326        // First tab should have no styling
3327        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        // Tab picker should show breadcrumb in list
3448        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        // Preview should show both service and tab name
3454        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        // Tab picker should have green border like other active controls
3461        let border_style = Style::default().fg(Color::Green);
3462        let border_type = BorderType::Plain;
3463
3464        // Verify green color is used
3465        assert_eq!(border_style.fg, Some(Color::Green));
3466        // Verify plain border type
3467        assert_eq!(border_type, BorderType::Plain);
3468    }
3469
3470    #[test]
3471    fn test_tab_picker_title_is_tabs() {
3472        // Tab picker should be titled "Tabs" not "Open Tabs"
3473        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        // S3 bucket type tabs should not show counts (only in table title)
3481        let general_purpose_tab = "General purpose buckets (All AWS Regions)";
3482        let directory_tab = "Directory buckets";
3483
3484        // Verify no count in tab labels
3485        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        // Count should only appear in table title
3491        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        // Should have 3 bucket columns (Name, Region, CreationDate)
3502        assert_eq!(app.s3_bucket_column_ids.len(), 3);
3503        assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
3504
3505        // Verify column names
3506        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        // S3 bucket columns should be different from CloudWatch log group columns
3516        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        // Verify they're different
3528        assert_ne!(bucket_col_names, log_col_names);
3529
3530        // Verify S3 columns don't contain CloudWatch-specific terms
3531        assert!(!bucket_col_names.contains(&"Log group".to_string()));
3532        assert!(!bucket_col_names.contains(&"Stored bytes".to_string()));
3533
3534        // Verify S3 columns contain S3-specific terms
3535        assert!(bucket_col_names.contains(&"Creation date".to_string()));
3536
3537        // Region should NOT be in bucket columns (shown only when expanded)
3538        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        // Initially 3 columns visible
3549        assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
3550
3551        // Simulate toggling off the Region column (index 1)
3552        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        // Toggle it back on
3567        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        // S3 bucket preferences should be titled "Preferences" without hints
3577        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        // ColumnSelector mode should show hotkey hints in status bar
3586        let help = " ↑↓: scroll | ␣: toggle | esc: close ";
3587
3588        // Verify key hints are present
3589        assert!(help.contains("␣: toggle"));
3590        assert!(help.contains("↑↓: scroll"));
3591        assert!(help.contains("esc: close"));
3592
3593        // Should NOT contain unavailable keys
3594        assert!(!help.contains("⏎"));
3595        assert!(!help.contains("^w"));
3596    }
3597
3598    #[test]
3599    fn test_date_range_title_no_hints() {
3600        // Date range title should not contain hints
3601        let title = " Date range ";
3602
3603        // Should NOT contain hints
3604        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        // EventFilterInput mode should show hints in status bar
3613        let help = " tab: switch | ␣: change unit | enter: apply | esc: cancel | ctrl+w: close ";
3614
3615        // Verify key hints are present
3616        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        // Should have 3 bucket columns (Name, Region, CreationDate)
3627        assert_eq!(app.s3_bucket_column_ids.len(), 3);
3628
3629        // All should be visible by default
3630        assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
3631
3632        // Verify all column names
3633        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        // S3 preferences should have green border (active state)
3646        let border_color = Color::Green;
3647        assert_eq!(border_color, Color::Green);
3648
3649        // Not cyan (inactive)
3650        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        // When in Normal mode, table should be active (green)
3663        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        // When in ColumnSelector mode, table should be inactive (white)
3673        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        // Tabs should be cleared before rendering to prevent artifacts
3686        // This is verified by the Clear widget being rendered before tabs
3687    }
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        // Properties tab should be selectable
3699        assert_eq!(app.s3_state.object_tab, S3ObjectTab::Properties);
3700
3701        // Properties scroll should start at 0
3702        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        // Initial scroll should be 0
3715        assert_eq!(app.s3_state.properties_scroll, 0);
3716
3717        // Scroll down
3718        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        // Scroll up
3725        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        // Should not go below 0
3732        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        // Parent prefix area should be cleared to prevent artifacts
3739        // Verified by Clear widget being rendered before parent text
3740    }
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        // When bucket region is empty, should default to us-east-1
3747        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        // When bucket region is set, should use it
3756        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        // Properties tab should have 12 separate blocks
3768        let block_count = 12;
3769        assert_eq!(block_count, 12);
3770
3771        // Blocks: Bucket overview, Tags, Default encryption, Intelligent-Tiering,
3772        // Server access logging, CloudTrail, Event notifications, EventBridge,
3773        // Transfer acceleration, Object Lock, Requester pays, Static website hosting
3774    }
3775
3776    #[test]
3777    fn test_s3_properties_tables_use_common_component() {
3778        // Tables should use ratatui Table widget
3779        // Tags table: Key, Value columns
3780        let tags_columns = ["Key", "Value"];
3781        assert_eq!(tags_columns.len(), 2);
3782
3783        // Intelligent-Tiering table: 5 columns
3784        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        // Event notifications table: 5 columns
3794        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        // Each field should have bold label followed by value
3807        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        // Verify label is bold
3817        assert!(label.spans[0].style.add_modifier.contains(Modifier::BOLD));
3818
3819        // Verify value is plain text
3820        assert!(!value.spans[0].style.add_modifier.contains(Modifier::BOLD));
3821    }
3822
3823    #[test]
3824    fn test_s3_properties_has_scrollbar() {
3825        // Properties tab should have vertical scrollbar
3826        let total_height = 7 + 5 + 6 + 5 + 4 + 4 + 5 + 4 + 4 + 4 + 4 + 4;
3827        assert_eq!(total_height, 56);
3828
3829        // If total height exceeds area, scrollbar should be shown
3830        let area_height = 40;
3831        assert!(total_height > area_height);
3832    }
3833
3834    #[test]
3835    fn test_s3_bucket_region_fetched_on_open() {
3836        // When bucket region is empty, it should be fetched before loading objects
3837        // This prevents PermanentRedirect errors
3838
3839        // Simulate empty region
3840        let empty_region = "";
3841        assert!(empty_region.is_empty());
3842
3843        // After fetch, region should be populated
3844        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        // When filter is hidden (non-Objects tabs), its space should be used by content
3851        // Objects tab: 4 chunks (prefix, tabs, filter, content)
3852        // Other tabs: 3 chunks (prefix, tabs, content)
3853
3854        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        // Properties should be scrollable
3867        assert_eq!(app.s3_state.properties_scroll, 0);
3868
3869        // Scroll down
3870        app.s3_state.properties_scroll += 1;
3871        assert_eq!(app.s3_state.properties_scroll, 1);
3872
3873        // Scroll up
3874        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        // Scrollbar should only show when content exceeds viewport
3881        let content_height = 40;
3882        let small_viewport = 20;
3883        let large_viewport = 50;
3884
3885        // Should show scrollbar
3886        assert!(content_height > small_viewport);
3887
3888        // Should not show scrollbar
3889        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        // Active tab should be yellow, bold, and underlined
3898        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        // Inactive tab should be gray
3907        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        // Field labels should be bold, values should not
3918        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        // Verify label is bold
3926        assert!(label.style.add_modifier.contains(Modifier::BOLD));
3927
3928        // Verify value is not bold
3929        assert!(!value.style.add_modifier.contains(Modifier::BOLD));
3930
3931        // Verify line has both parts
3932        assert_eq!(line.spans.len(), 2);
3933    }
3934
3935    #[test]
3936    fn test_session_picker_dialog_opaque() {
3937        // Session picker dialog should use Clear widget to be opaque
3938        // This prevents background content from showing through
3939    }
3940
3941    #[test]
3942    fn test_status_bar_hotkey_format() {
3943        // Status bar should use ⋮ separator, ^ for ctrl, uppercase for shift+char, and highlight keys in red
3944
3945        // Test separator
3946        let separator = " ⋮ ";
3947        assert_eq!(separator, " ⋮ ");
3948
3949        // Test ctrl format
3950        let ctrl_key = "^r";
3951        assert!(ctrl_key.starts_with("^"));
3952        assert!(!ctrl_key.contains("ctrl+"));
3953        assert!(!ctrl_key.contains("ctrl-"));
3954
3955        // Test shift+char format (uppercase)
3956        let shift_key = "^R";
3957        assert!(shift_key.contains("^R"));
3958        assert!(!shift_key.contains("shift+"));
3959        assert!(!shift_key.contains("shift-"));
3960
3961        // Test that old formats are not used
3962        let old_separator = " | ";
3963        assert_ne!(separator, old_separator);
3964    }
3965
3966    #[test]
3967    fn test_space_key_uses_unicode_symbol() {
3968        // Space key should use ␣ (U+2423 OPEN BOX) symbol, not "space" text
3969        let space_symbol = "␣";
3970        assert_eq!(space_symbol, "␣");
3971        assert_eq!(space_symbol.len(), 3); // UTF-8 bytes
3972
3973        // Should not use text "space"
3974        assert_ne!(space_symbol, "space");
3975        assert_ne!(space_symbol, "SPC");
3976    }
3977
3978    #[test]
3979    fn test_region_hotkey_uses_space_menu() {
3980        // Region should use ␣→r (space menu), not ^R (Ctrl+Shift+R)
3981        let region_hotkey = "␣→r";
3982        assert_eq!(region_hotkey, "␣→r");
3983
3984        // Should not use ^R for region
3985        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        // This test validates that common hotkey mistakes are not present in the UI code
3992        let source = include_str!("mod.rs");
3993
3994        // Split at #[cfg(test)] to only check non-test code
3995        let ui_code = if let Some(pos) = source.find("#[cfg(test)]") {
3996            &source[..pos]
3997        } else {
3998            source
3999        };
4000
4001        // Check for "space" text instead of ␣ symbol in hotkeys
4002        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        // Check for ^R followed by region (should be ␣→r)
4009        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        // Region switching should ONLY be in Space menu, NOT in status bar hotkeys
4027        let source = include_str!("mod.rs");
4028
4029        // Find the space menu section
4030        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        // Verify region IS in space menu
4040        assert!(
4041            space_menu_code.contains(r#"Span::raw(" regions")"#),
4042            "Region must be in Space menu"
4043        );
4044
4045        // Find status bar section (render_bottom_bar)
4046        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        // Verify region is NOT in status bar
4056        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        // PermanentRedirect errors should be silently handled
4073        // Empty preview should be inserted to prevent retry
4074        let error_msg = "PermanentRedirect";
4075        assert!(error_msg.contains("PermanentRedirect"));
4076
4077        // Verify empty preview prevents retry
4078        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        // Hint should say "open" not "open folder" or "drill down"
4087        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        // Service tabs should use cyan color when active
4096        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        // Column names should use orange (LightRed) color
4104        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        // Bucket errors should be stored and displayed in expanded rows
4111        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        // Page input should work for CloudWatch alarms
4121        let mut app = test_app();
4122        app.current_service = Service::CloudWatchAlarms;
4123        app.page_input = "2".to_string();
4124
4125        // Verify page input is set
4126        assert_eq!(app.page_input, "2");
4127    }
4128
4129    #[test]
4130    fn test_tabs_row_shows_profile_info() {
4131        // Tabs row should show profile, account, region, identity, and timestamp
4132        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        // Profile info labels should use bold modifier
4151        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        // Profile info should only appear once (in tabs row, not in top bar)
4160        // Top bar should only show breadcrumbs
4161        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        // All table column headers should use Cyan color
4169        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        // Nested objects (second level folders) should be expandable
4179        // Visual index should map to actual object including nested items
4180        let mut app = test_app();
4181        app.current_service = Service::S3Buckets;
4182        app.s3_state.current_bucket = Some("bucket".to_string());
4183
4184        // Add a top-level folder
4185        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        // Expand it
4194        app.s3_state
4195            .expanded_prefixes
4196            .insert("folder1/".to_string());
4197
4198        // Add nested folder in preview
4199        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        // Visual index 1 should be the nested folder
4211        app.s3_state.selected_object = 1;
4212
4213        // Should be able to expand nested folder
4214        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        // Add parent folder
4226        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        // Expand parent and add nested folder
4235        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        // Nested folder should show ▶ when collapsed
4248        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        // After expanding, should show ▼
4254        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        // Tabs row should always be visible (shows profile info)
4265        // Even when on service picker
4266        let app = test_app();
4267        assert!(!app.service_selected); // On service picker
4268                                        // Tabs row should still render with profile info
4269    }
4270
4271    #[test]
4272    fn test_no_duplicate_breadcrumbs_at_root() {
4273        // When at root level (e.g., CloudWatch › Alarms), don't show duplicate breadcrumb
4274        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        // At root level, breadcrumb should not be shown separately
4284        // (it's already in the tab)
4285        assert_eq!(app.breadcrumbs(), "CloudWatch > Alarms");
4286    }
4287
4288    #[test]
4289    fn test_preferences_headers_use_cyan_underline() {
4290        // Preferences section headers should use cyan with underline, not box drawing
4291        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        // Should not use box drawing characters
4299        let header_text = "Columns";
4300        assert!(!header_text.contains("═"));
4301    }
4302
4303    #[test]
4304    fn test_alarm_pagination_shows_actual_pages() {
4305        // Pagination should show "Page X of Y", not page size selector
4306        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        // Mode indicator should say "INSERT" not "INPUT"
4320        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        // Service picker should show INSERT mode when filter is not empty
4328        let mut app = test_app();
4329        app.mode = Mode::ServicePicker;
4330        app.service_picker.filter = "cloud".to_string();
4331
4332        // Should show INSERT mode
4333        assert!(!app.service_picker.filter.is_empty());
4334    }
4335
4336    #[test]
4337    fn test_log_events_no_horizontal_scrollbar() {
4338        // Log events should not show horizontal scrollbar
4339        // Only vertical scrollbar for navigating events
4340        // Message column truncates with ellipsis, expand to see full content
4341        let app = test_app();
4342
4343        // Log events only have 2 columns: Timestamp and Message
4344        // No horizontal scrolling needed - message truncates
4345        assert_eq!(app.cw_log_event_visible_column_ids.len(), 2);
4346
4347        // Horizontal scroll offset should not be used for events
4348        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        // Expanded log event should stay visible when scrolling to other events
4354        // Same behavior as CloudWatch Alarms
4355        let mut app = test_app();
4356
4357        // Expand event at index 0
4358        app.log_groups_state.expanded_event = Some(0);
4359        app.log_groups_state.event_scroll_offset = 0;
4360
4361        // Scroll to event 1
4362        app.log_groups_state.event_scroll_offset = 1;
4363
4364        // Expanded event should still be set and visible
4365        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        // Right arrow - should expand
4385        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        // Left arrow - should collapse
4405        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        // Expanded content should replace tabs with spaces to avoid rendering artifacts
4412        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        // When navigating down from an expanded event, selection should skip to next event
4423        // Empty rows are added to table to reserve space, but navigation uses event indices
4424        let mut app = test_app();
4425
4426        // Expand event at index 0
4427        app.log_groups_state.expanded_event = Some(0);
4428        app.log_groups_state.event_scroll_offset = 0;
4429
4430        // Navigate down - should go to event 1, not expanded overlay lines
4431        app.log_groups_state.event_scroll_offset = 1;
4432
4433        // Selection is now on event 1
4434        assert_eq!(app.log_groups_state.event_scroll_offset, 1);
4435
4436        // Expanded event 0 is still expanded
4437        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        // Empty rows are added to table for expanded content to prevent overlay from covering next events
4443        // This ensures selection highlight is visible on the correct row
4444        let message = "Long message that will wrap across multiple lines when expanded";
4445        let max_width = 50;
4446
4447        // Calculate how many lines this would take
4448        let full_line = format!("Message: {}", message);
4449        let line_count = full_line.len().div_ceil(max_width);
4450
4451        // Should be at least 2 lines for this message
4452        assert!(line_count >= 2);
4453
4454        // Empty rows equal to line_count should be added to reserve space
4455        // This prevents the overlay from covering the next event's selection highlight
4456    }
4457
4458    #[test]
4459    fn test_preferences_title_no_hints() {
4460        // All preferences dialogs should have clean titles without hints
4461        // Hints should be in status bar instead
4462        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        // No hints in titles
4471        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        // Page navigation (e.g., "2P") should work for log events
4479        let mut app = test_app();
4480        app.view_mode = ViewMode::Events;
4481
4482        // Simulate having 50 events
4483        app.log_groups_state.event_scroll_offset = 0;
4484
4485        // Navigate to page 2 (page_size = 20, so target_index = 20)
4486        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        // After navigation, page_input should be cleared
4493        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        // Alarms preferences should show Tab hint in status bar (has multiple sections)
4500        // Other preferences don't need Tab hint
4501        let app = test_app();
4502
4503        // Alarms has sections: Columns, View As, Page Size, Wrap Lines
4504        // So it needs Tab navigation hint
4505        assert_eq!(app.current_service, Service::CloudWatchLogGroups);
4506
4507        // When current_service is CloudWatchAlarms, Tab hint should be shown
4508        // This is checked in the status bar rendering logic
4509    }
4510
4511    #[test]
4512    fn test_column_selector_shows_correct_columns_per_service() {
4513        use crate::app::Service;
4514
4515        // S3 Buckets should show bucket columns
4516        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        // CloudWatch Log Groups should show log group columns
4526        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        // CloudWatch Alarms should show alarm columns
4545        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        // Verify all 6 columns exist
4560        assert_eq!(app.cw_log_group_column_ids.len(), 6);
4561
4562        // Verify each column by name
4563        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        // Verify stream columns exist
4584        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        // Verify event columns exist
4596        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        // Verify alarm columns exist
4608        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        // Column selector should have scrollbar when items don't fit
4615        // This is rendered in render_column_selector after the list widget
4616        let item_count = 6; // Log groups has 6 columns
4617        assert!(item_count > 0);
4618
4619        // Scrollbar should be rendered with vertical right orientation
4620        // with up/down arrows
4621    }
4622
4623    #[test]
4624    fn test_preferences_scrollbar_only_when_needed() {
4625        // Scrollbar should only appear when content exceeds available height
4626        let item_count = 6;
4627        let height = (item_count as u16 + 2).max(8); // +2 for borders
4628        let max_height_fits = 20; // Large enough to fit all items
4629        let max_height_doesnt_fit = 5; // Too small to fit all items
4630
4631        // When content fits, no scrollbar needed
4632        let needs_scrollbar_fits = height > max_height_fits;
4633        assert!(!needs_scrollbar_fits);
4634
4635        // When content doesn't fit, scrollbar needed
4636        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        // Height should be item_count + 2 (for borders), not + 4
4643        let item_count = 6;
4644        let height = (item_count as u16 + 2).max(8);
4645        assert_eq!(height, 8); // 6 + 2 = 8
4646
4647        // Should not have extra empty lines
4648        assert_ne!(height, 10); // Not 6 + 4
4649    }
4650
4651    #[test]
4652    fn test_preferences_uses_absolute_sizing() {
4653        // Preferences should use centered_rect_absolute, not centered_rect (percentages)
4654        // This ensures width/height are in characters, not percentages
4655        let width = 50u16; // 50 characters
4656        let height = 10u16; // 10 lines
4657
4658        // These are absolute values, not percentages
4659        assert!(width <= 100); // Reasonable character width
4660        assert!(height <= 50); // Reasonable line height
4661    }
4662
4663    #[test]
4664    fn test_profile_picker_shows_sort_indicator() {
4665        // Profile picker should show sort on Profile column ascending
4666        let sort_column = "Profile";
4667        let sort_direction = "ASC";
4668
4669        assert_eq!(sort_column, "Profile");
4670        assert_eq!(sort_direction, "ASC");
4671
4672        // Verify arrow would be added
4673        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        // Session picker should show sort on Timestamp column descending
4684        let sort_column = "Timestamp";
4685        let sort_direction = "DESC";
4686
4687        assert_eq!(sort_column, "Timestamp");
4688        assert_eq!(sort_direction, "DESC");
4689
4690        // Verify arrow would be added
4691        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        // Sessions should be added in descending timestamp order (newest first)
4728        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        // Sessions are already sorted descending by timestamp (newest first)
4751        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        // When in FilterInput mode, table should not be active
4807        assert_eq!(app.mode, Mode::FilterInput);
4808        // This would be checked in render logic: is_active: app.mode != Mode::FilterInput
4809    }
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        // When in FilterInput mode, table should not be active
4828        assert_eq!(app.mode, Mode::FilterInput);
4829        // This would be checked in render logic: is_active: app.mode != Mode::FilterInput
4830    }
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        // Simulate Escape key (CloseMenu action)
4840        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        // ECR repos table should not show scrollbar when all paginated items fit
4848        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        // With 50 repos on page and typical terminal height, scrollbar should not appear
4861        // Scrollbar logic: row_count > (area_height - 3)
4862        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        // Simulate opening preferences and changing page size
5108        app.mode = Mode::ColumnSelector;
5109        // Index for page size options: 0=Columns header, 1-9=columns, 10=empty, 11=PageSize header, 12=10, 13=25, 14=50, 15=100
5110        app.column_selector_index = 12; // 10 resources
5111        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        // Verify all page sizes are available
5123        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        // Test with page size 10
5159        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        // Test with page size 25
5166        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        // Test with page size 50
5173        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        // Start at columns section
5187        app.column_selector_index = 0;
5188        app.handle_action(crate::keymap::Action::NextPreferences);
5189
5190        // Should jump to page size section (9 columns + 1 empty + 1 header = 11)
5191        assert_eq!(app.column_selector_index, 11);
5192
5193        // Next should cycle back to columns
5194        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        // Drill down into function
5222        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        // List view
5272        let breadcrumb = app.breadcrumbs();
5273        assert_eq!(breadcrumb, "Lambda > Functions");
5274
5275        // Detail view
5276        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        // List view
5288        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        // Detail view
5295        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        // Verify format matches our (UTC) pattern
5322        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; // In list view
5361        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        // func2 should be first (newer)
5429        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        // Test collapsed state
5477        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        // Test expanded state
5483        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        // Verify symbols are different
5489        assert_ne!(symbol_collapsed, symbol_expanded);
5490    }
5491
5492    #[test]
5493    fn test_lambda_last_modified_column_width() {
5494        // Verify width is sufficient for "2025-10-31 08:37:46 (UTC)" (25 chars)
5495        let timestamp = "2025-10-31 08:37:46 (UTC)";
5496        assert_eq!(timestamp.len(), 25);
5497
5498        // Column width should be at least 27 to have some padding
5499        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        // Verify we're in Code tab
5526        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
5527
5528        // Verify function exists
5529        assert!(app.lambda_state.current_function.is_some());
5530        assert_eq!(app.lambda_state.table.items.len(), 1);
5531
5532        // Info section should have: Package size, SHA256 hash, Last modified
5533        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        // Create 25 functions
5548        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        // Start at index 0 (page 0)
5567        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        // Navigate to index 10 (page 1)
5574        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        // Navigate to index 15 (page 1, item 5)
5580        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        // Create 100 functions (simulating real scenario)
5595        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        // Page 0: items 0-49
5617        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        // Page 1: items 50-99
5626        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        // Verify pagination text
5635        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        // When active (not in FilterInput mode), pagination should be green, border white
5645        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); // White (default)
5658
5659        // When in FilterInput mode, pagination should be white (default), border yellow
5660        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); // White (default)
5672        assert_eq!(border_style.fg, Some(Color::Yellow));
5673    }
5674
5675    #[test]
5676    fn test_lambda_application_expansion_indicator() {
5677        // Lambda applications should show expansion indicator like ECR repos
5678        let app_name = "my-application";
5679
5680        // Collapsed state
5681        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        // Expanded state
5686        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        // ECR should use TableState's page_size, not hardcoded value
5694        let mut app = test_app_no_region();
5695        app.current_service = Service::EcrRepositories;
5696
5697        // Create 100 repositories
5698        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        // Set page size to 25
5709        app.ecr_state.repositories.page_size = crate::common::PageSize::TwentyFive;
5710
5711        // Select item 30 (should be on page 1, index 5 within page)
5712        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); // 30 % 25 = 5
5719    }
5720
5721    #[test]
5722    fn test_ecr_repository_selection_indicator_visible() {
5723        // Verify selection indicator calculation matches table rendering
5724        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        // Should be active (not in FilterInput mode)
5751        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        // ECR repository name column should use format_expandable to show ► indicator
5760        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        // Collapsed state should show ►
5769        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        // Expanded state should show ▼
5774        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        // Status in expanded content should show emoji for complete states
5782        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        // Test CREATE_COMPLETE
5806        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        // Test that section heights are calculated based on content, not fixed
5843        // Overview: 14 fields + 2 borders = 16
5844        let overview_fields = 14;
5845        let overview_height = overview_fields + 2;
5846        assert_eq!(overview_height, 16);
5847
5848        // Tags (empty): 4 lines + 2 borders = 6
5849        let tags_empty_lines = 4;
5850        let tags_empty_height = tags_empty_lines + 2;
5851        assert_eq!(tags_empty_height, 6);
5852
5853        // Stack policy (empty): 5 lines + 2 borders = 7
5854        let policy_empty_lines = 5;
5855        let policy_empty_height = policy_empty_lines + 2;
5856        assert_eq!(policy_empty_height, 7);
5857
5858        // Rollback (empty): 6 lines + 2 borders = 8
5859        let rollback_empty_lines = 6;
5860        let rollback_empty_height = rollback_empty_lines + 2;
5861        assert_eq!(rollback_empty_height, 8);
5862
5863        // Notifications (empty): 4 lines + 2 borders = 6
5864        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        // Verify log_groups uses TableState
5875        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        // Add test log groups
5890        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        // Test filtering
5924        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        // Test pagination
5929        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        // Test collapsed state (►)
5947        let collapsed = crate::ui::table::format_expandable(&group.name, false);
5948        assert!(collapsed.starts_with("► "));
5949        assert!(collapsed.contains("/aws/lambda/test"));
5950
5951        // Test expanded state (▼)
5952        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        // Verify visual boundary constants exist
5960        assert_eq!(crate::ui::table::CURSOR_COLLAPSED, "►");
5961        assert_eq!(crate::ui::table::CURSOR_EXPANDED, "▼");
5962
5963        // The visual boundaries │ and ╰ are rendered in render_table()
5964        // They are added as prefixes to expanded content lines
5965        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        // Right arrow - should expand
5994        app.handle_action(Action::NextPane);
5995        assert_eq!(app.log_groups_state.log_groups.expanded_item, Some(0));
5996
5997        // Left arrow - should collapse
5998        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        // Right arrow - should expand
6019        app.handle_action(Action::NextPane);
6020        assert_eq!(app.log_groups_state.expanded_stream, Some(0));
6021
6022        // Left arrow - should collapse
6023        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        // Verify that log events don't use BorderType::Double
6030        // The new style only uses Green fg color for active state
6031        let mut app = test_app();
6032        app.current_service = Service::CloudWatchLogGroups;
6033        app.service_selected = true;
6034        app.view_mode = ViewMode::Events;
6035
6036        // Border style should only be Green fg when active, not Double border type
6037        // This is a regression test to ensure we don't reintroduce Double borders
6038        assert_eq!(app.view_mode, ViewMode::Events);
6039    }
6040
6041    #[test]
6042    fn test_log_group_detail_border_style_no_double_border() {
6043        // Verify that log group detail doesn't use BorderType::Double
6044        let mut app = test_app();
6045        app.current_service = Service::CloudWatchLogGroups;
6046        app.service_selected = true;
6047        app.view_mode = ViewMode::Detail;
6048
6049        // Border style should only be Green fg when active, not Double border type
6050        assert_eq!(app.view_mode, ViewMode::Detail);
6051    }
6052
6053    #[test]
6054    fn test_expansion_uses_intermediate_field_indicator() {
6055        // Verify that expanded content uses ├ for intermediate fields
6056        // This is tested by checking the constants exist
6057        // The actual rendering logic uses:
6058        // - ├ for field starts (lines with ": ")
6059        // - │ for continuation lines
6060        // - ╰ for the last line
6061
6062        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        // Verify expansion is set
6087        assert_eq!(app.log_groups_state.expanded_stream, Some(0));
6088
6089        // Verify stream exists
6090        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        // Verify that filter, exact match, and show expired are on the same line
6097        // This is a visual test - we verify the constraint is Length(3) not Length(4)
6098        let _app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
6099
6100        // Filter area should be 3 lines (1 for content + 2 for borders)
6101        // not 4 lines (2 for content + 2 for borders)
6102        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        // Create 100 log groups
6115        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        // Set page size to 50
6132        app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
6133
6134        // Go to item 49 (last on page 1, 0-indexed)
6135        app.log_groups_state.log_groups.selected = 49;
6136
6137        // Press down - should go to item 50 (first on page 2)
6138        app.handle_action(Action::NextItem);
6139        assert_eq!(app.log_groups_state.log_groups.selected, 50);
6140
6141        // Press up - should go back to item 49
6142        app.handle_action(Action::PrevItem);
6143        assert_eq!(app.log_groups_state.log_groups.selected, 49);
6144
6145        // Go to item 50 again
6146        app.handle_action(Action::NextItem);
6147        assert_eq!(app.log_groups_state.log_groups.selected, 50);
6148
6149        // Press up again - should still go to 49
6150        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        // Create 100 log groups
6163        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        // Go to last item (99)
6180        app.log_groups_state.log_groups.selected = 99;
6181
6182        // Press down - should stay at 99
6183        app.handle_action(Action::NextItem);
6184        assert_eq!(app.log_groups_state.log_groups.selected, 99);
6185
6186        // Press up - should go to 98
6187        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        // Create 100 log groups
6200        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        // Set page size to 50
6217        app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
6218
6219        // Start at item 49 (last visible on first viewport)
6220        app.log_groups_state.log_groups.selected = 49;
6221        app.log_groups_state.log_groups.scroll_offset = 0;
6222
6223        // Press down - should go to item 50 and scroll viewport
6224        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); // Scrolled by 1
6227
6228        // Press up - should go back to item 49 WITHOUT scrolling back
6229        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); // Still at 1, not 0
6232
6233        // Press up again - should go to 48, still no scroll
6234        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); // Still at 1
6237
6238        // Keep going up until we hit the top of viewport (item 1)
6239        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); // Still at 1
6244
6245        // One more up - should scroll viewport up
6246        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); // Scrolled to 0
6249    }
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        // Create 100 log groups
6260        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        // Set page size to 50
6277        app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
6278
6279        // Go to last row (99) with scroll showing last page
6280        app.log_groups_state.log_groups.selected = 99;
6281        app.log_groups_state.log_groups.scroll_offset = 50; // Showing items 50-99
6282
6283        // Press up - should go to item 98 WITHOUT scrolling
6284        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); // Should NOT scroll
6287
6288        // Press up again - should go to 97, still no scroll
6289        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); // Should NOT scroll
6292    }
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        // Create 100 log groups
6303        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        // Set page size to 50
6320        app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
6321
6322        // Simulate: at item 49 (last visible), press down to get to item 50
6323        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        // Now at item 50, scroll_offset = 1 (showing items 1-50)
6328        assert_eq!(app.log_groups_state.log_groups.selected, 50);
6329        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1);
6330
6331        // Item 50 is now the last visible row
6332        // Press up - should move to item 49 WITHOUT scrolling
6333        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        // Create 100 stacks
6352        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        // Set page size to 50
6378        app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
6379
6380        // Simulate: at item 49 (last visible), press down to get to item 50
6381        app.cfn_state.table.selected = 49;
6382        app.cfn_state.table.scroll_offset = 0;
6383        app.handle_action(Action::NextItem);
6384
6385        // Now at item 50, scroll_offset should be 1
6386        assert_eq!(app.cfn_state.table.selected, 50);
6387        assert_eq!(app.cfn_state.table.scroll_offset, 1);
6388
6389        // Press up - should move to item 49 WITHOUT scrolling
6390        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        // Create 88 stacks (like in the user's screenshot)
6409        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        // Set page size to 50
6435        app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
6436
6437        // Simulate being on page 2 (showing items 38-87, which is the last page)
6438        // User is at item 87 (the actual last row)
6439        app.cfn_state.table.selected = 87;
6440        app.cfn_state.table.scroll_offset = 38; // Showing last 50 items
6441
6442        // Press up - should move to item 86 WITHOUT scrolling
6443        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        // Add test users
6490        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        // No filter - should return all users
6526        let filtered = crate::ui::iam::filtered_iam_users(&app);
6527        assert_eq!(filtered.len(), 2);
6528
6529        // Filter by name
6530        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        // Case insensitive filter
6536        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        // Add 30 test users
6548        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        // Default page size is 25
6568        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        // Pagination should work
6574        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        // Expand first item
6603        app.handle_action(Action::NextPane);
6604        assert_eq!(app.iam_state.users.expanded_item, Some(0));
6605
6606        // Collapse
6607        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); // Tab1, separator, Tab2, separator, Tab3
6731        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); // First, separator, Second
6746        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        // Width 30, fields ~9 chars, should fit 3 columns, 3 fields / 3 = 1 row
6766        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        // Width 20, fields ~9 chars, should fit 2 columns, 5 fields / 2 = 3 rows (3,2)
6779        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        // Width 20, short fields ~4 chars, should fit 5 columns, 10 fields / 5 = 2 rows
6797        // But if max_field_width is calculated differently, adjust expectation
6798        let result = calculate_dynamic_height(&fields, 20);
6799        // With 10 fields at width 20: max_field_width = 4 (2 + 2), columns = 20/4 = 5
6800        // But with MAX_DETAIL_COLUMNS=3, columns = min(5, 3, 10) = 3
6801        // 10 fields / 3 columns = 3 base, 1 extra = 4 rows
6802        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        // Width 100, should fit many columns, but MAX_DETAIL_COLUMNS=3
6814        // 4 fields / 3 columns = 1 base, 1 extra = 2 rows
6815        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        // Render to a test backend to verify column selector shows tag columns
6827        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        // Should show "Key" and "Value" columns, not "Log stream"
6846        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        // Should show "Log stream" for CloudWatch
6877        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        // Should show EC2 instance columns, not tag columns
6905        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        // Should start with "─ "
6919        assert!(text.starts_with("─ "));
6920        // Should contain the section name
6921        assert!(text.contains("Default encryption"));
6922        // Should end with dashes
6923        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        // Total character count should match width (not byte count)
6937        assert_eq!(text.chars().count(), width as usize);
6938        // Format: "─ Test Section ───...─"
6939        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        // Add a log group
6958        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        // Breadcrumb should not be shown in UI (show_breadcrumbs should be false)
6970        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        // Breadcrumb should not be shown in UI
6995        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        // format_title returns "─ Title ─"
7008        // Ratatui block renders: ╭<title>╮
7009        // Result: ╭─ Title ─╮
7010
7011        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        // Should render as: ╭─ AWS Services ─...
7047        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        // Should render as: ╭─ CloudTrail Events ─...
7077        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        // true/false/null/~ must be colored yellow/darkgray
7087        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        // "key: value" — key is blue, value follows scalar rules
7123        let spans = highlight_yaml_line("myKey: true");
7124        // spans: [key_span, colon, space, value_span]
7125        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        // First span is the "- " dash in cyan
7146        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        // JSON starts with '{' — should use JSON path (no YAML-specific blue key coloring)
7159        // We just verify it doesn't panic and the detection function exists.
7160        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        // render_summary now delegates to render_fields_with_dynamic_columns.
7175        // Verify that calculate_dynamic_height with a wide area returns fewer
7176        // rows than with a narrow area (i.e. columns are being used).
7177        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        // Labels passed with trailing ": " must NOT produce "Label: : value"
7207        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        // Labels without ": " should still get ": " appended
7223        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}