rusticity_term/ui/
mod.rs

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