Skip to main content

rusticity_term/ui/
mod.rs

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