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