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