Skip to main content

rusticity_term/
app.rs

1use crate::apig::api::Column as ApigColumn;
2use crate::apig::resource::Column as ResourceColumn;
3use crate::apig::route::Column as RouteColumn;
4pub use crate::aws::{filter_profiles, Profile as AwsProfile, Region as AwsRegion};
5use crate::cfn::{Column as CfnColumn, Stack as CfnStack};
6use crate::clipboard::ClipboardState;
7use crate::cloudtrail::{CloudTrailEvent, CloudTrailEventColumn, EventResourceColumn};
8use crate::common::{ColumnId, CyclicEnum, InputFocus, PageSize, SortDirection};
9pub use crate::cw::insights::InsightsFocus;
10use crate::cw::insights::InsightsState;
11pub use crate::cw::{Alarm, AlarmColumn};
12use crate::dynamodb::table::Column as DynamoDbColumn;
13use crate::dynamodb::{actions as ddb_actions, items as ddb_items};
14pub use crate::ec2::{Column as Ec2Column, Instance as Ec2Instance};
15use crate::ecr::image::{Column as EcrImageColumn, Image as EcrImage};
16use crate::ecr::repo::{Column as EcrColumn, Repository as EcrRepository};
17use crate::efs::fs::{Column as EfsColumn, FileSystem as EfsFileSystem};
18use crate::fsx::fs::Column as FsxColumn;
19use crate::iam::{
20    GroupUser as IamGroupUser, Policy as IamPolicy, RoleColumn, RoleTag as IamRoleTag, UserColumn,
21    UserTag as IamUserTag,
22};
23#[cfg(test)]
24use crate::iam::{IamRole, IamUser, LastAccessedService};
25use crate::keymap::{Action, Mode};
26use crate::kms::key::Key as KmsKey;
27pub use crate::lambda::{
28    Alias as LambdaAlias, Application as LambdaApplication,
29    ApplicationColumn as LambdaApplicationColumn, Deployment, DeploymentColumn,
30    Function as LambdaFunction, FunctionColumn as LambdaColumn, Layer as LambdaLayer, Resource,
31    ResourceColumn as LambdaResourceColumn, Version as LambdaVersion,
32};
33use crate::rds::actions as rds_actions;
34pub use crate::s3::{Bucket as S3Bucket, BucketColumn as S3BucketColumn, Object as S3Object};
35use crate::session::{Session, SessionTab};
36pub use crate::sqs::queue::Column as SqsColumn;
37pub use crate::sqs::trigger::Column as SqsTriggerColumn;
38#[cfg(test)]
39use crate::sqs::{
40    EventBridgePipe, LambdaTrigger, Queue as SqsQueue, QueueTag as SqsQueueTag, SnsSubscription,
41};
42use crate::table::TableState;
43pub use crate::ui::apig::{
44    filtered_apis, State as ApigState, FILTER_CONTROLS as APIG_FILTER_CONTROLS,
45};
46pub use crate::ui::cfn::{
47    change_set_column_ids, change_set_visible_column_ids, event_column_ids,
48    event_visible_column_ids, filtered_change_sets, filtered_cloudformation_stacks,
49    filtered_events, filtered_outputs, filtered_parameters, filtered_resources, output_column_ids,
50    parameter_column_ids, resource_column_ids, DetailTab as CfnDetailTab,
51    EventsView as CfnEventsView, State as CfnState, StatusFilter as CfnStatusFilter,
52};
53pub use crate::ui::cw::alarms::{
54    AlarmDetailTab, AlarmTab, AlarmViewMode, FILTER_CONTROLS as ALARM_FILTER_CONTROLS,
55};
56pub use crate::ui::cw::logs::{
57    filtered_log_events, filtered_log_groups, filtered_log_streams, selected_log_group,
58    DetailTab as CwLogsDetailTab, EventFilterFocus, FILTER_CONTROLS as LOG_FILTER_CONTROLS,
59};
60pub use crate::ui::dynamodb::State as DynamoDbState;
61use crate::ui::dynamodb::{ItemOperation, ItemProjection, ITEM_LIMIT};
62pub use crate::ui::ec2::{
63    DetailTab as Ec2DetailTab, State as Ec2State, StateFilter as Ec2StateFilter,
64    STATE_FILTER as EC2_STATE_FILTER,
65};
66pub use crate::ui::ecr::{
67    filtered_ecr_images, filtered_ecr_repositories, State as EcrState, Tab as EcrTab,
68    FILTER_CONTROLS as ECR_FILTER_CONTROLS,
69};
70pub use crate::ui::efs::State as EfsState;
71pub use crate::ui::fsx::State as FsxState;
72use crate::ui::iam::{GroupTab, RoleTab, State as IamState, UserTab};
73pub use crate::ui::kms::{State as KmsState, Tab as KmsTab};
74pub use crate::ui::lambda::{
75    filtered_lambda_applications, filtered_lambda_functions,
76    ApplicationDetailTab as LambdaApplicationDetailTab, ApplicationState as LambdaApplicationState,
77    DetailTab as LambdaDetailTab, State as LambdaState, FILTER_CONTROLS as LAMBDA_FILTER_CONTROLS,
78};
79use crate::ui::monitoring::MonitoringState;
80use crate::ui::rds::State as RdsState;
81pub use crate::ui::s3::{
82    calculate_total_bucket_rows, calculate_total_object_rows, BucketType as S3BucketType,
83    ObjectTab as S3ObjectTab, State as S3State,
84};
85pub use crate::ui::sqs::{
86    extract_account_id, extract_region, filtered_eventbridge_pipes, filtered_lambda_triggers,
87    filtered_queues, filtered_subscriptions, filtered_tags, QueueDetailTab as SqsQueueDetailTab,
88    State as SqsState, FILTER_CONTROLS as SQS_FILTER_CONTROLS,
89    SUBSCRIPTION_FILTER_CONTROLS as SQS_SUBSCRIPTION_FILTER_CONTROLS, SUBSCRIPTION_REGION,
90};
91use crate::ui::vpc::State as VpcState;
92pub use crate::ui::{
93    CloudWatchLogGroupsState, DateRangeType, DetailTab, EventColumn, LogGroupColumn, Preferences,
94    StreamColumn, StreamSort, TimeUnit,
95};
96use crate::vpc::actions as vpc_actions;
97use rusticity_core::dynamodb::DdbItemsPage;
98#[cfg(test)]
99use rusticity_core::LogStream;
100use rusticity_core::{
101    AlarmsClient, ApiGatewayClient, AwsConfig, CloudFormationClient, CloudTrailClient,
102    CloudWatchClient, DynamoDbClient, Ec2Client, EcrClient, EfsClient, FsxClient, IamClient,
103    KmsClient, LambdaClient, S3Client, SqsClient,
104};
105use rusticity_core::{RdsClient, VpcClient};
106
107#[derive(Clone)]
108pub struct Tab {
109    pub service: Service,
110    pub title: String,
111    pub breadcrumb: String,
112}
113
114pub struct App {
115    pub running: bool,
116    pub mode: Mode,
117    pub config: AwsConfig,
118    pub cloudwatch_client: CloudWatchClient,
119    pub cloudtrail_client: CloudTrailClient,
120    pub s3_client: S3Client,
121    pub sqs_client: SqsClient,
122    pub alarms_client: AlarmsClient,
123    pub ec2_client: Ec2Client,
124    pub ecr_client: EcrClient,
125    pub kms_client: KmsClient,
126    pub efs_client: EfsClient,
127    pub fsx_client: FsxClient,
128    pub rds_client: RdsClient,
129    pub vpc_client: VpcClient,
130    pub dynamodb_client: DynamoDbClient,
131    pub apig_client: ApiGatewayClient,
132    pub iam_client: IamClient,
133    pub lambda_client: LambdaClient,
134    pub cloudformation_client: CloudFormationClient,
135    pub current_service: Service,
136    pub tabs: Vec<Tab>,
137    pub current_tab: usize,
138    pub tab_picker_selected: usize,
139    pub tab_filter: String,
140    pub pending_key: Option<char>,
141    pub log_groups_state: CloudWatchLogGroupsState,
142    pub insights_state: CloudWatchInsightsState,
143    pub alarms_state: CloudWatchAlarmsState,
144    pub cloudtrail_state: CloudTrailState,
145    pub s3_state: S3State,
146    pub sqs_state: SqsState,
147    pub ec2_state: Ec2State,
148    pub ecr_state: EcrState,
149    pub kms_state: KmsState,
150    pub efs_state: EfsState,
151    pub fsx_state: FsxState,
152    pub rds_state: RdsState,
153    pub vpc_state: VpcState,
154    pub resource_finder: crate::finder::State,
155    pub dynamodb_state: DynamoDbState,
156    pub apig_state: ApigState,
157    pub lambda_state: LambdaState,
158    pub lambda_application_state: LambdaApplicationState,
159    pub cfn_state: CfnState,
160    pub iam_state: IamState,
161    pub service_picker: ServicePickerState,
162    pub service_selected: bool,
163    pub profile: String,
164    pub region: String,
165    pub region_selector_index: usize,
166    pub cw_log_group_visible_column_ids: Vec<ColumnId>,
167    pub cw_log_group_column_ids: Vec<ColumnId>,
168    pub column_selector_index: usize,
169    pub preference_section: Preferences,
170    pub cw_log_stream_visible_column_ids: Vec<ColumnId>,
171    pub cw_log_stream_column_ids: Vec<ColumnId>,
172    pub cw_log_event_visible_column_ids: Vec<ColumnId>,
173    pub cw_log_event_column_ids: Vec<ColumnId>,
174    pub cw_log_tag_visible_column_ids: Vec<ColumnId>,
175    pub cw_log_tag_column_ids: Vec<ColumnId>,
176    pub cw_alarm_visible_column_ids: Vec<ColumnId>,
177    pub cw_alarm_column_ids: Vec<ColumnId>,
178    pub cloudtrail_event_visible_column_ids: Vec<ColumnId>,
179    pub cloudtrail_event_column_ids: Vec<ColumnId>,
180    pub cloudtrail_resource_visible_column_ids: Vec<ColumnId>,
181    pub cloudtrail_resource_column_ids: Vec<ColumnId>,
182    pub s3_bucket_visible_column_ids: Vec<ColumnId>,
183    pub s3_bucket_column_ids: Vec<ColumnId>,
184    pub sqs_visible_column_ids: Vec<ColumnId>,
185    pub sqs_column_ids: Vec<ColumnId>,
186    pub ec2_visible_column_ids: Vec<ColumnId>,
187    pub ec2_column_ids: Vec<ColumnId>,
188    pub ecr_repo_visible_column_ids: Vec<ColumnId>,
189    pub ecr_repo_column_ids: Vec<ColumnId>,
190    pub ecr_image_visible_column_ids: Vec<ColumnId>,
191    pub ecr_image_column_ids: Vec<ColumnId>,
192    pub efs_visible_column_ids: Vec<ColumnId>,
193    pub efs_column_ids: Vec<ColumnId>,
194    pub fsx_visible_column_ids: Vec<ColumnId>,
195    pub fsx_column_ids: Vec<ColumnId>,
196    pub dynamodb_visible_column_ids: Vec<ColumnId>,
197    pub dynamodb_column_ids: Vec<ColumnId>,
198    pub apig_api_visible_column_ids: Vec<ColumnId>,
199    pub apig_api_column_ids: Vec<ColumnId>,
200    pub apig_route_visible_column_ids: Vec<ColumnId>,
201    pub apig_route_column_ids: Vec<ColumnId>,
202    pub apig_resource_visible_column_ids: Vec<ColumnId>,
203    pub apig_resource_column_ids: Vec<ColumnId>,
204    pub lambda_application_visible_column_ids: Vec<ColumnId>,
205    pub lambda_application_column_ids: Vec<ColumnId>,
206    pub lambda_deployment_visible_column_ids: Vec<ColumnId>,
207    pub lambda_deployment_column_ids: Vec<ColumnId>,
208    pub lambda_resource_visible_column_ids: Vec<ColumnId>,
209    pub lambda_resource_column_ids: Vec<ColumnId>,
210    pub cfn_visible_column_ids: Vec<ColumnId>,
211    pub cfn_column_ids: Vec<ColumnId>,
212    pub cfn_parameter_visible_column_ids: Vec<ColumnId>,
213    pub cfn_parameter_column_ids: Vec<ColumnId>,
214    pub cfn_output_visible_column_ids: Vec<ColumnId>,
215    pub cfn_output_column_ids: Vec<ColumnId>,
216    pub cfn_resource_visible_column_ids: Vec<ColumnId>,
217    pub cfn_resource_column_ids: Vec<ColumnId>,
218    pub cfn_event_visible_column_ids: Vec<ColumnId>,
219    pub cfn_event_column_ids: Vec<ColumnId>,
220    pub cfn_change_set_visible_column_ids: Vec<ColumnId>,
221    pub cfn_change_set_column_ids: Vec<ColumnId>,
222    pub iam_user_visible_column_ids: Vec<ColumnId>,
223    pub iam_user_column_ids: Vec<ColumnId>,
224    pub iam_role_visible_column_ids: Vec<ColumnId>,
225    pub iam_role_column_ids: Vec<ColumnId>,
226    pub iam_group_visible_column_ids: Vec<String>,
227    pub iam_group_column_ids: Vec<String>,
228    pub iam_policy_visible_column_ids: Vec<String>,
229    pub iam_policy_column_ids: Vec<String>,
230    pub view_mode: ViewMode,
231    pub error_message: Option<String>,
232    pub error_scroll: usize,
233    pub page_input: String,
234    pub calendar_date: Option<time::Date>,
235    pub calendar_selecting: CalendarField,
236    pub cursor_pos: usize,
237    pub current_session: Option<Session>,
238    pub sessions: Vec<Session>,
239    pub session_picker_selected: usize,
240    pub session_filter: String,
241    pub session_filter_active: bool,
242    pub region_filter: String,
243    pub region_picker_selected: usize,
244    pub region_filter_active: bool,
245    pub region_latencies: std::collections::HashMap<String, u64>,
246    pub profile_filter: String,
247    pub profile_picker_selected: usize,
248    pub profile_filter_active: bool,
249    pub available_profiles: Vec<AwsProfile>,
250    pub clipboard: ClipboardState,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq)]
254pub enum CalendarField {
255    StartDate,
256    EndDate,
257}
258
259pub struct CloudWatchInsightsState {
260    pub insights: InsightsState,
261    pub loading: bool,
262}
263
264pub struct CloudWatchAlarmsState {
265    pub table: TableState<Alarm>,
266    pub current_alarm: Option<String>,
267    pub alarm_tab: AlarmTab,
268    pub detail_tab: AlarmDetailTab,
269    pub view_as: AlarmViewMode,
270    pub wrap_lines: bool,
271    pub sort_column: String,
272    pub sort_direction: SortDirection,
273    pub input_focus: InputFocus,
274    pub metric_data: Vec<(i64, f64)>,
275    pub metrics_loading: bool,
276}
277
278#[derive(Debug, Clone)]
279pub struct CloudTrailState {
280    pub table: TableState<CloudTrailEvent>,
281    pub input_focus: InputFocus,
282    pub current_event: Option<CloudTrailEvent>,
283    pub event_json_scroll: usize,
284    pub detail_focus: CloudTrailDetailFocus,
285    pub resources_expanded_index: Option<usize>,
286    /// The EventName filter currently applied via API (set on Enter, cleared on empty filter).
287    pub active_event_name_filter: Option<String>,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq)]
291pub enum CloudTrailDetailFocus {
292    Resources,
293    EventRecord,
294}
295
296impl CyclicEnum for CloudTrailDetailFocus {
297    const ALL: &'static [Self] = &[Self::Resources, Self::EventRecord];
298}
299
300impl PageSize {
301    pub fn value(&self) -> usize {
302        match self {
303            PageSize::Ten => 10,
304            PageSize::TwentyFive => 25,
305            PageSize::Fifty => 50,
306            PageSize::OneHundred => 100,
307        }
308    }
309
310    pub fn next(&self) -> Self {
311        match self {
312            PageSize::Ten => PageSize::TwentyFive,
313            PageSize::TwentyFive => PageSize::Fifty,
314            PageSize::Fifty => PageSize::OneHundred,
315            PageSize::OneHundred => PageSize::Ten,
316        }
317    }
318}
319
320pub struct ServicePickerState {
321    pub filter: String,
322    pub filter_active: bool,
323    pub selected: usize,
324    pub services: Vec<&'static str>,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq)]
328pub enum ViewMode {
329    List,
330    Detail,
331    Events,
332    InsightsResults,
333    PolicyView,
334}
335
336#[derive(Debug, Clone, Copy, PartialEq)]
337pub enum Service {
338    ApiGatewayApis,
339    CloudWatchLogGroups,
340    CloudWatchInsights,
341    CloudWatchAlarms,
342    CloudTrailEvents,
343    RdsInstances,
344    S3Buckets,
345    SqsQueues,
346    VpcVpcs,
347    Ec2Instances,
348    EcrRepositories,
349    KmsKeys,
350    EfsFileSystems,
351    FsxFileSystems,
352    DynamoDbTables,
353    LambdaFunctions,
354    LambdaApplications,
355    CloudFormationStacks,
356    IamUsers,
357    IamRoles,
358    IamUserGroups,
359}
360
361impl Service {
362    pub fn name(&self) -> &str {
363        match self {
364            Service::ApiGatewayApis => "API Gateway › APIs",
365            Service::CloudWatchLogGroups => "CloudWatch › Log Groups",
366            Service::CloudWatchInsights => "CloudWatch › Logs Insights",
367            Service::CloudWatchAlarms => "CloudWatch › Alarms",
368            Service::CloudTrailEvents => "CloudTrail › Event History",
369            Service::RdsInstances => "RDS › Databases",
370            Service::S3Buckets => "S3 › Buckets",
371            Service::SqsQueues => "SQS › Queues",
372            Service::VpcVpcs => "VPC › VPCs",
373            Service::Ec2Instances => "EC2 › Instances",
374            Service::EcrRepositories => "ECR › Repositories",
375            Service::KmsKeys => "KMS › Managed Keys",
376            Service::EfsFileSystems => "EFS › File Systems",
377            Service::FsxFileSystems => "FSx › File Systems",
378            Service::DynamoDbTables => "DynamoDB › Tables",
379            Service::LambdaFunctions => "Lambda › Functions",
380            Service::LambdaApplications => "Lambda › Applications",
381            Service::CloudFormationStacks => "CloudFormation › Stacks",
382            Service::IamUsers => "IAM › Users",
383            Service::IamRoles => "IAM › Roles",
384            Service::IamUserGroups => "IAM › User Groups",
385        }
386    }
387}
388
389pub(crate) fn nav_page_down(selected: &mut usize, max: usize, page_size: usize) {
390    if max > 0 {
391        *selected = (*selected + page_size).min(max - 1);
392    }
393}
394
395impl App {
396    pub fn get_input_focus(&self) -> InputFocus {
397        InputFocus::Filter
398    }
399
400    fn get_active_filter_mut(&mut self) -> Option<&mut String> {
401        if crate::tags::Source::active(self).is_some() {
402            return crate::tags::active_filter_mut(self);
403        }
404        if self.current_service == Service::VpcVpcs {
405            return Some(&mut self.vpc_state.vpcs.filter);
406        }
407        if self.current_service == Service::RdsInstances {
408            return Some(&mut self.rds_state.instances.filter);
409        }
410        if self.current_service == Service::ApiGatewayApis {
411            crate::apig::actions::get_active_filter_mut(self)
412        } else if self.current_service == Service::CloudWatchAlarms {
413            Some(&mut self.alarms_state.table.filter)
414        } else if self.current_service == Service::CloudTrailEvents {
415            Some(&mut self.cloudtrail_state.table.filter)
416        } else if self.current_service == Service::Ec2Instances {
417            crate::ec2::actions::get_active_filter_mut(self)
418        } else if self.current_service == Service::S3Buckets {
419            if self.s3_state.current_bucket.is_some() {
420                Some(&mut self.s3_state.object_filter)
421            } else {
422                Some(&mut self.s3_state.buckets.filter)
423            }
424        } else if self.current_service == Service::EcrRepositories {
425            if self.ecr_state.current_repository.is_some() {
426                Some(&mut self.ecr_state.images.filter)
427            } else {
428                Some(&mut self.ecr_state.repositories.filter)
429            }
430        } else if self.current_service == Service::SqsQueues {
431            crate::sqs::actions::get_active_filter_mut(self)
432        } else if self.current_service == Service::LambdaFunctions {
433            crate::lambda::functions::get_active_filter_mut(self)
434        } else if self.current_service == Service::LambdaApplications {
435            crate::lambda::applications::get_active_filter_mut(self)
436        } else if self.current_service == Service::KmsKeys {
437            crate::kms::actions::get_active_filter_mut(self)
438        } else if self.current_service == Service::EfsFileSystems {
439            crate::efs::actions::get_active_filter_mut(self)
440        } else if self.current_service == Service::DynamoDbTables {
441            ddb_actions::get_active_filter_mut(self)
442        } else if self.current_service == Service::FsxFileSystems {
443            crate::fsx::actions::get_active_filter_mut(self)
444        } else if self.current_service == Service::CloudFormationStacks {
445            if self.cfn_state.current_stack.is_some()
446                && self.cfn_state.detail_tab == CfnDetailTab::Resources
447            {
448                Some(&mut self.cfn_state.resources.filter)
449            } else {
450                Some(&mut self.cfn_state.table.filter)
451            }
452        } else if self.current_service == Service::IamUsers {
453            if self.iam_state.current_user.is_some() {
454                if self.iam_state.user_tab == UserTab::Tags {
455                    Some(&mut self.iam_state.user_tags.filter)
456                } else {
457                    Some(&mut self.iam_state.policies.filter)
458                }
459            } else {
460                Some(&mut self.iam_state.users.filter)
461            }
462        } else if self.current_service == Service::IamRoles {
463            if self.iam_state.current_role.is_some() {
464                if self.iam_state.role_tab == RoleTab::Tags {
465                    Some(&mut self.iam_state.tags.filter)
466                } else if self.iam_state.role_tab == RoleTab::LastAccessed {
467                    Some(&mut self.iam_state.last_accessed_filter)
468                } else {
469                    Some(&mut self.iam_state.policies.filter)
470                }
471            } else {
472                Some(&mut self.iam_state.roles.filter)
473            }
474        } else if self.current_service == Service::IamUserGroups {
475            if self.iam_state.current_group.is_some() {
476                if self.iam_state.group_tab == GroupTab::Permissions {
477                    Some(&mut self.iam_state.policies.filter)
478                } else if self.iam_state.group_tab == GroupTab::Users {
479                    Some(&mut self.iam_state.group_users.filter)
480                } else {
481                    None
482                }
483            } else {
484                Some(&mut self.iam_state.groups.filter)
485            }
486        } else if self.view_mode == ViewMode::List {
487            Some(&mut self.log_groups_state.log_groups.filter)
488        } else if self.view_mode == ViewMode::Detail
489            && self.log_groups_state.detail_tab == DetailTab::LogStreams
490        {
491            Some(&mut self.log_groups_state.stream_filter)
492        } else {
493            None
494        }
495    }
496
497    fn apply_filter_operation<F>(&mut self, op: F)
498    where
499        F: FnOnce(&mut String),
500    {
501        if let Some(filter) = self.get_active_filter_mut() {
502            op(filter);
503            if crate::tags::reset_active(self) {
504                return;
505            }
506            if self.current_service == Service::RdsInstances {
507                self.rds_state.instances.reset();
508            } else if self.current_service == Service::VpcVpcs {
509                self.vpc_state.vpcs.reset();
510            }
511            // Automatically reset selection for all services
512            if self.current_service == Service::CloudWatchAlarms {
513                crate::cw::actions::alarms_apply_filter_reset(self);
514            } else if self.current_service == Service::CloudTrailEvents {
515                crate::cloudtrail::actions::apply_filter_reset(self);
516            } else if self.current_service == Service::Ec2Instances {
517                crate::ec2::actions::apply_filter_reset(self);
518            } else if self.current_service == Service::S3Buckets {
519                crate::s3::actions::apply_filter_reset(self);
520            } else if self.current_service == Service::EcrRepositories {
521                crate::ecr::actions::apply_filter_reset(self);
522            } else if self.current_service == Service::KmsKeys {
523                crate::kms::actions::apply_filter_reset(self);
524            } else if self.current_service == Service::EfsFileSystems {
525                crate::efs::actions::apply_filter_reset(self);
526            } else if self.current_service == Service::DynamoDbTables {
527                ddb_actions::apply_filter_reset(self)
528            } else if self.current_service == Service::FsxFileSystems {
529                crate::fsx::actions::apply_filter_reset(self);
530            } else if self.current_service == Service::SqsQueues {
531                crate::sqs::actions::apply_filter_reset(self);
532            } else if self.current_service == Service::LambdaFunctions {
533                crate::lambda::functions::apply_filter_reset(self);
534            } else if self.current_service == Service::LambdaApplications {
535                crate::lambda::applications::apply_filter_reset(self);
536            } else if self.current_service == Service::CloudFormationStacks {
537                crate::cfn::actions::apply_filter_reset(self);
538            } else if self.current_service == Service::IamUsers {
539                crate::iam::actions::apply_filter_reset_users(self);
540            } else if self.current_service == Service::IamRoles {
541                crate::iam::actions::apply_filter_reset_roles(self);
542            } else if self.current_service == Service::IamUserGroups {
543                crate::iam::actions::apply_filter_reset_groups(self);
544            } else if self.current_service == Service::CloudWatchLogGroups {
545                if self.view_mode == ViewMode::List {
546                    self.log_groups_state.log_groups.reset();
547                } else if self.log_groups_state.detail_tab == DetailTab::LogStreams {
548                    self.log_groups_state.selected_stream = 0;
549                }
550            } else if self.current_service == Service::ApiGatewayApis {
551                crate::apig::actions::apply_filter_reset(self);
552            }
553        }
554    }
555
556    pub async fn new(profile: Option<String>, region: Option<String>) -> anyhow::Result<Self> {
557        let profile_name = profile.or_else(|| std::env::var("AWS_PROFILE").ok())
558            .ok_or_else(|| anyhow::anyhow!("No AWS profile specified. Set AWS_PROFILE environment variable or select a profile."))?;
559
560        std::env::set_var("AWS_PROFILE", &profile_name);
561
562        let config = AwsConfig::new(region).await?;
563        let cloudwatch_client = CloudWatchClient::new(config.clone()).await?;
564        let cloudtrail_client = CloudTrailClient::new(config.clone());
565        let s3_client = S3Client::new(config.clone());
566        let sqs_client = SqsClient::new(config.clone());
567        let alarms_client = AlarmsClient::new(config.clone());
568        let ec2_client = Ec2Client::new(config.clone());
569        let ecr_client = EcrClient::new(config.clone());
570        let apig_client = ApiGatewayClient::new(config.clone());
571        let iam_client = IamClient::new(config.clone());
572        let kms_client = KmsClient::new(config.clone());
573        let efs_client = EfsClient::new(config.clone());
574        let fsx_client = FsxClient::new(config.clone());
575        let rds_client = RdsClient::new(config.clone());
576        let vpc_client = VpcClient::new(config.clone());
577        let dynamodb_client = DynamoDbClient::new(config.clone());
578        let lambda_client = LambdaClient::new(config.clone());
579        let cloudformation_client = CloudFormationClient::new(config.clone());
580        let region_name = config.region.clone();
581
582        Ok(Self {
583            running: true,
584            mode: Mode::ServicePicker,
585            config,
586            cloudwatch_client,
587            cloudtrail_client,
588            s3_client,
589            sqs_client,
590            alarms_client,
591            ec2_client,
592            ecr_client,
593            kms_client,
594            efs_client,
595            fsx_client,
596            rds_client,
597            vpc_client,
598            dynamodb_client,
599            apig_client,
600            iam_client,
601            lambda_client,
602            cloudformation_client,
603            current_service: Service::CloudWatchLogGroups,
604            tabs: Vec::new(),
605            current_tab: 0,
606            tab_picker_selected: 0,
607            tab_filter: String::new(),
608            pending_key: None,
609            log_groups_state: CloudWatchLogGroupsState::new(),
610            insights_state: CloudWatchInsightsState::new(),
611            alarms_state: CloudWatchAlarmsState::new(),
612            cloudtrail_state: CloudTrailState {
613                table: TableState::new(),
614                input_focus: InputFocus::Filter,
615                current_event: None,
616                event_json_scroll: 0,
617                detail_focus: CloudTrailDetailFocus::Resources,
618                resources_expanded_index: None,
619                active_event_name_filter: None,
620            },
621            s3_state: S3State::new(),
622            sqs_state: SqsState::new(),
623            ec2_state: Ec2State::default(),
624            ecr_state: EcrState::new(),
625            kms_state: KmsState::new(),
626            efs_state: EfsState::new(),
627            fsx_state: FsxState::new(),
628            rds_state: RdsState::default(),
629            vpc_state: VpcState::default(),
630            resource_finder: crate::finder::State::default(),
631            dynamodb_state: DynamoDbState::new(),
632            apig_state: ApigState::new(),
633            lambda_state: LambdaState::new(),
634            lambda_application_state: LambdaApplicationState::new(),
635            cfn_state: CfnState::new(),
636            iam_state: IamState::new(),
637            service_picker: ServicePickerState::new(),
638            service_selected: false,
639            profile: profile_name,
640            region: region_name,
641            region_selector_index: 0,
642            cw_log_group_visible_column_ids: LogGroupColumn::default_visible(),
643            cw_log_group_column_ids: LogGroupColumn::ids(),
644            column_selector_index: 0,
645            cw_log_stream_visible_column_ids: StreamColumn::default_visible(),
646            cw_log_stream_column_ids: StreamColumn::ids(),
647            cw_log_event_visible_column_ids: EventColumn::default_visible(),
648            cw_log_event_column_ids: EventColumn::ids(),
649            cw_log_tag_visible_column_ids: crate::cw::TagColumn::ids(),
650            cw_log_tag_column_ids: crate::cw::TagColumn::ids(),
651            cw_alarm_visible_column_ids: [
652                AlarmColumn::Name,
653                AlarmColumn::State,
654                AlarmColumn::LastStateUpdate,
655                AlarmColumn::Conditions,
656                AlarmColumn::Actions,
657            ]
658            .iter()
659            .map(|c| c.id())
660            .collect(),
661            cw_alarm_column_ids: AlarmColumn::ids(),
662            cloudtrail_event_visible_column_ids: [
663                CloudTrailEventColumn::EventName,
664                CloudTrailEventColumn::EventTime,
665                CloudTrailEventColumn::Username,
666                CloudTrailEventColumn::EventSource,
667                CloudTrailEventColumn::ResourceType,
668                CloudTrailEventColumn::ResourceName,
669            ]
670            .iter()
671            .map(|c| c.id())
672            .collect(),
673            cloudtrail_event_column_ids: CloudTrailEventColumn::ids(),
674            cloudtrail_resource_visible_column_ids: EventResourceColumn::ids(),
675            cloudtrail_resource_column_ids: EventResourceColumn::ids(),
676            s3_bucket_visible_column_ids: S3BucketColumn::ids(),
677            s3_bucket_column_ids: S3BucketColumn::ids(),
678            sqs_visible_column_ids: [
679                SqsColumn::Name,
680                SqsColumn::Type,
681                SqsColumn::Created,
682                SqsColumn::MessagesAvailable,
683                SqsColumn::MessagesInFlight,
684                SqsColumn::Encryption,
685                SqsColumn::ContentBasedDeduplication,
686            ]
687            .iter()
688            .map(|c| c.id())
689            .collect(),
690            sqs_column_ids: SqsColumn::ids(),
691            ec2_visible_column_ids: [
692                Ec2Column::Name,
693                Ec2Column::InstanceId,
694                Ec2Column::InstanceState,
695                Ec2Column::InstanceType,
696                Ec2Column::StatusCheck,
697                Ec2Column::AlarmStatus,
698                Ec2Column::AvailabilityZone,
699                Ec2Column::PublicIpv4Dns,
700                Ec2Column::PublicIpv4Address,
701                Ec2Column::ElasticIp,
702                Ec2Column::Ipv6Ips,
703                Ec2Column::Monitoring,
704                Ec2Column::SecurityGroupName,
705                Ec2Column::KeyName,
706                Ec2Column::LaunchTime,
707                Ec2Column::PlatformDetails,
708            ]
709            .iter()
710            .map(|c| c.id())
711            .collect(),
712            ec2_column_ids: Ec2Column::ids(),
713            ecr_repo_visible_column_ids: EcrColumn::ids(),
714            ecr_repo_column_ids: EcrColumn::ids(),
715            ecr_image_visible_column_ids: EcrImageColumn::ids(),
716            ecr_image_column_ids: EcrImageColumn::ids(),
717            efs_visible_column_ids: EfsColumn::default_visible_ids(),
718            efs_column_ids: EfsColumn::ids(),
719            fsx_visible_column_ids: FsxColumn::default_visible_ids(),
720            fsx_column_ids: FsxColumn::ids(),
721            dynamodb_visible_column_ids: DynamoDbColumn::default_visible_ids(),
722            dynamodb_column_ids: DynamoDbColumn::ids(),
723            apig_api_visible_column_ids: ApigColumn::ids(),
724            apig_api_column_ids: ApigColumn::ids(),
725            apig_route_visible_column_ids: RouteColumn::all().iter().map(|c| c.id()).collect(),
726            apig_route_column_ids: RouteColumn::all().iter().map(|c| c.id()).collect(),
727            apig_resource_visible_column_ids: ResourceColumn::all()
728                .iter()
729                .map(|c| c.id())
730                .collect(),
731            apig_resource_column_ids: ResourceColumn::all().iter().map(|c| c.id()).collect(),
732            lambda_application_visible_column_ids: LambdaApplicationColumn::visible(),
733            lambda_application_column_ids: LambdaApplicationColumn::ids(),
734            lambda_deployment_visible_column_ids: DeploymentColumn::ids(),
735            lambda_deployment_column_ids: DeploymentColumn::ids(),
736            lambda_resource_visible_column_ids: crate::lambda::ResourceColumn::ids(),
737            lambda_resource_column_ids: crate::lambda::ResourceColumn::ids(),
738            cfn_visible_column_ids: [
739                CfnColumn::Name,
740                CfnColumn::Status,
741                CfnColumn::CreatedTime,
742                CfnColumn::Description,
743            ]
744            .iter()
745            .map(|c| c.id())
746            .collect(),
747            cfn_column_ids: CfnColumn::ids(),
748            cfn_parameter_visible_column_ids: parameter_column_ids(),
749            cfn_parameter_column_ids: parameter_column_ids(),
750            cfn_output_visible_column_ids: output_column_ids(),
751            cfn_output_column_ids: output_column_ids(),
752            cfn_resource_visible_column_ids: resource_column_ids(),
753            cfn_resource_column_ids: resource_column_ids(),
754            cfn_event_visible_column_ids: event_visible_column_ids(),
755            cfn_event_column_ids: event_column_ids(),
756            cfn_change_set_visible_column_ids: change_set_visible_column_ids(),
757            cfn_change_set_column_ids: change_set_column_ids(),
758            iam_user_visible_column_ids: UserColumn::visible(),
759            iam_user_column_ids: UserColumn::ids(),
760            iam_role_visible_column_ids: RoleColumn::visible(),
761            iam_role_column_ids: RoleColumn::ids(),
762            iam_group_visible_column_ids: vec![
763                "Group name".to_string(),
764                "Users".to_string(),
765                "Permissions".to_string(),
766                "Creation time".to_string(),
767            ],
768            iam_group_column_ids: vec![
769                "Group name".to_string(),
770                "Path".to_string(),
771                "Users".to_string(),
772                "Permissions".to_string(),
773                "Creation time".to_string(),
774            ],
775            iam_policy_visible_column_ids: vec![
776                "Policy name".to_string(),
777                "Type".to_string(),
778                "Attached via".to_string(),
779            ],
780            iam_policy_column_ids: vec![
781                "Policy name".to_string(),
782                "Type".to_string(),
783                "Attached via".to_string(),
784                "Attached entities".to_string(),
785                "Description".to_string(),
786                "Creation time".to_string(),
787                "Edited time".to_string(),
788            ],
789            preference_section: Preferences::Columns,
790            view_mode: ViewMode::List,
791            error_message: None,
792            error_scroll: 0,
793            page_input: String::new(),
794            calendar_date: None,
795            calendar_selecting: CalendarField::StartDate,
796            cursor_pos: 0,
797            current_session: None,
798            sessions: Vec::new(),
799            session_picker_selected: 0,
800            session_filter: String::new(),
801            session_filter_active: false,
802            region_filter: String::new(),
803            region_filter_active: false,
804            region_picker_selected: 0,
805            region_latencies: std::collections::HashMap::new(),
806            profile_filter: String::new(),
807            profile_filter_active: false,
808            profile_picker_selected: 0,
809            available_profiles: Vec::new(),
810            clipboard: ClipboardState::default(),
811        })
812    }
813
814    pub fn new_without_client(profile: String, region: Option<String>) -> Self {
815        let config = AwsConfig::dummy(region.clone());
816        Self {
817            running: true,
818            mode: Mode::ServicePicker,
819            config: config.clone(),
820            cloudwatch_client: CloudWatchClient::dummy(config.clone()),
821            cloudtrail_client: CloudTrailClient::new(config.clone()),
822            s3_client: S3Client::new(config.clone()),
823            sqs_client: SqsClient::new(config.clone()),
824            alarms_client: AlarmsClient::new(config.clone()),
825            ec2_client: Ec2Client::new(config.clone()),
826            ecr_client: EcrClient::new(config.clone()),
827            kms_client: KmsClient::new(config.clone()),
828            efs_client: EfsClient::new(config.clone()),
829            fsx_client: FsxClient::new(config.clone()),
830            rds_client: RdsClient::new(config.clone()),
831            vpc_client: VpcClient::new(config.clone()),
832            dynamodb_client: DynamoDbClient::new(config.clone()),
833            apig_client: ApiGatewayClient::new(config.clone()),
834            iam_client: IamClient::new(config.clone()),
835            lambda_client: LambdaClient::new(config.clone()),
836            cloudformation_client: CloudFormationClient::new(config.clone()),
837            current_service: Service::CloudWatchLogGroups,
838            tabs: Vec::new(),
839            current_tab: 0,
840            tab_picker_selected: 0,
841            tab_filter: String::new(),
842            pending_key: None,
843            log_groups_state: CloudWatchLogGroupsState::new(),
844            insights_state: CloudWatchInsightsState::new(),
845            alarms_state: CloudWatchAlarmsState::new(),
846            s3_state: S3State::new(),
847            cloudtrail_state: CloudTrailState {
848                table: TableState::new(),
849                input_focus: InputFocus::Filter,
850                current_event: None,
851                event_json_scroll: 0,
852                detail_focus: CloudTrailDetailFocus::Resources,
853                resources_expanded_index: None,
854                active_event_name_filter: None,
855            },
856            sqs_state: SqsState::new(),
857            ec2_state: Ec2State::default(),
858            ecr_state: EcrState::new(),
859            kms_state: KmsState::new(),
860            efs_state: EfsState::new(),
861            fsx_state: FsxState::new(),
862            rds_state: RdsState::default(),
863            vpc_state: VpcState::default(),
864            resource_finder: crate::finder::State::default(),
865            dynamodb_state: DynamoDbState::new(),
866            apig_state: ApigState::new(),
867            lambda_state: LambdaState::new(),
868            lambda_application_state: LambdaApplicationState::new(),
869            cfn_state: CfnState::new(),
870            iam_state: IamState::new(),
871            service_picker: ServicePickerState::new(),
872            service_selected: false,
873            profile,
874            region: region.unwrap_or_default(),
875            region_selector_index: 0,
876            cw_log_group_visible_column_ids: LogGroupColumn::default_visible(),
877            cw_log_group_column_ids: LogGroupColumn::ids(),
878            column_selector_index: 0,
879            preference_section: Preferences::Columns,
880            cw_log_stream_visible_column_ids: StreamColumn::default_visible(),
881            cw_log_stream_column_ids: StreamColumn::ids(),
882            cw_log_event_visible_column_ids: EventColumn::default_visible(),
883            cw_log_event_column_ids: EventColumn::ids(),
884            cw_log_tag_visible_column_ids: crate::cw::TagColumn::ids(),
885            cw_log_tag_column_ids: crate::cw::TagColumn::ids(),
886            cw_alarm_visible_column_ids: [
887                AlarmColumn::Name,
888                AlarmColumn::State,
889                AlarmColumn::LastStateUpdate,
890                AlarmColumn::Conditions,
891                AlarmColumn::Actions,
892            ]
893            .iter()
894            .map(|c| c.id())
895            .collect(),
896            cw_alarm_column_ids: AlarmColumn::ids(),
897            s3_bucket_visible_column_ids: S3BucketColumn::ids(),
898            cloudtrail_event_visible_column_ids: [
899                CloudTrailEventColumn::EventName,
900                CloudTrailEventColumn::EventTime,
901                CloudTrailEventColumn::Username,
902                CloudTrailEventColumn::EventSource,
903                CloudTrailEventColumn::ResourceType,
904                CloudTrailEventColumn::ResourceName,
905            ]
906            .iter()
907            .map(|c| c.id())
908            .collect(),
909            cloudtrail_event_column_ids: CloudTrailEventColumn::ids(),
910            cloudtrail_resource_visible_column_ids: EventResourceColumn::ids(),
911            cloudtrail_resource_column_ids: EventResourceColumn::ids(),
912            s3_bucket_column_ids: S3BucketColumn::ids(),
913            sqs_visible_column_ids: [
914                SqsColumn::Name,
915                SqsColumn::Type,
916                SqsColumn::Created,
917                SqsColumn::MessagesAvailable,
918                SqsColumn::MessagesInFlight,
919                SqsColumn::Encryption,
920                SqsColumn::ContentBasedDeduplication,
921            ]
922            .iter()
923            .map(|c| c.id())
924            .collect(),
925            sqs_column_ids: SqsColumn::ids(),
926            ec2_visible_column_ids: [
927                Ec2Column::Name,
928                Ec2Column::InstanceId,
929                Ec2Column::InstanceState,
930                Ec2Column::InstanceType,
931                Ec2Column::StatusCheck,
932                Ec2Column::AlarmStatus,
933                Ec2Column::AvailabilityZone,
934                Ec2Column::PublicIpv4Dns,
935                Ec2Column::PublicIpv4Address,
936                Ec2Column::ElasticIp,
937                Ec2Column::Ipv6Ips,
938                Ec2Column::Monitoring,
939                Ec2Column::SecurityGroupName,
940                Ec2Column::KeyName,
941                Ec2Column::LaunchTime,
942                Ec2Column::PlatformDetails,
943            ]
944            .iter()
945            .map(|c| c.id())
946            .collect(),
947            ec2_column_ids: Ec2Column::ids(),
948            ecr_repo_visible_column_ids: EcrColumn::ids(),
949            ecr_repo_column_ids: EcrColumn::ids(),
950            ecr_image_visible_column_ids: EcrImageColumn::ids(),
951            ecr_image_column_ids: EcrImageColumn::ids(),
952            efs_visible_column_ids: EfsColumn::default_visible_ids(),
953            efs_column_ids: EfsColumn::ids(),
954            fsx_visible_column_ids: FsxColumn::default_visible_ids(),
955            fsx_column_ids: FsxColumn::ids(),
956            dynamodb_visible_column_ids: DynamoDbColumn::default_visible_ids(),
957            dynamodb_column_ids: DynamoDbColumn::ids(),
958            lambda_application_visible_column_ids: LambdaApplicationColumn::visible(),
959            lambda_application_column_ids: LambdaApplicationColumn::ids(),
960            apig_api_visible_column_ids: ApigColumn::ids(),
961            apig_api_column_ids: ApigColumn::ids(),
962            apig_route_visible_column_ids: RouteColumn::all().iter().map(|c| c.id()).collect(),
963            apig_route_column_ids: RouteColumn::all().iter().map(|c| c.id()).collect(),
964            apig_resource_visible_column_ids: ResourceColumn::all()
965                .iter()
966                .map(|c| c.id())
967                .collect(),
968            apig_resource_column_ids: ResourceColumn::all().iter().map(|c| c.id()).collect(),
969            lambda_deployment_visible_column_ids: DeploymentColumn::ids(),
970            lambda_deployment_column_ids: DeploymentColumn::ids(),
971            lambda_resource_visible_column_ids: crate::lambda::ResourceColumn::ids(),
972            lambda_resource_column_ids: crate::lambda::ResourceColumn::ids(),
973            cfn_visible_column_ids: [
974                CfnColumn::Name,
975                CfnColumn::Status,
976                CfnColumn::CreatedTime,
977                CfnColumn::Description,
978            ]
979            .iter()
980            .map(|c| c.id())
981            .collect(),
982            cfn_column_ids: CfnColumn::ids(),
983            iam_user_visible_column_ids: UserColumn::visible(),
984            cfn_parameter_visible_column_ids: parameter_column_ids(),
985            cfn_parameter_column_ids: parameter_column_ids(),
986            cfn_output_visible_column_ids: output_column_ids(),
987            cfn_output_column_ids: output_column_ids(),
988            cfn_resource_visible_column_ids: resource_column_ids(),
989            cfn_resource_column_ids: resource_column_ids(),
990            cfn_event_visible_column_ids: event_visible_column_ids(),
991            cfn_event_column_ids: event_column_ids(),
992            cfn_change_set_visible_column_ids: change_set_visible_column_ids(),
993            cfn_change_set_column_ids: change_set_column_ids(),
994            iam_user_column_ids: UserColumn::ids(),
995            iam_role_visible_column_ids: RoleColumn::visible(),
996            iam_role_column_ids: RoleColumn::ids(),
997            iam_group_visible_column_ids: vec![
998                "Group name".to_string(),
999                "Users".to_string(),
1000                "Permissions".to_string(),
1001                "Creation time".to_string(),
1002            ],
1003            iam_group_column_ids: vec![
1004                "Group name".to_string(),
1005                "Path".to_string(),
1006                "Users".to_string(),
1007                "Permissions".to_string(),
1008                "Creation time".to_string(),
1009            ],
1010            iam_policy_visible_column_ids: vec![
1011                "Policy name".to_string(),
1012                "Type".to_string(),
1013                "Attached via".to_string(),
1014            ],
1015            iam_policy_column_ids: vec![
1016                "Policy name".to_string(),
1017                "Type".to_string(),
1018                "Attached via".to_string(),
1019                "Attached entities".to_string(),
1020                "Description".to_string(),
1021                "Creation time".to_string(),
1022                "Edited time".to_string(),
1023            ],
1024            view_mode: ViewMode::List,
1025            error_message: None,
1026            error_scroll: 0,
1027            page_input: String::new(),
1028            calendar_date: None,
1029            calendar_selecting: CalendarField::StartDate,
1030            cursor_pos: 0,
1031            current_session: None,
1032            sessions: Vec::new(),
1033            session_picker_selected: 0,
1034            session_filter: String::new(),
1035            session_filter_active: false,
1036            region_filter: String::new(),
1037            region_filter_active: false,
1038            region_picker_selected: 0,
1039            region_latencies: std::collections::HashMap::new(),
1040            profile_filter: String::new(),
1041            profile_filter_active: false,
1042            profile_picker_selected: 0,
1043            available_profiles: Vec::new(),
1044            clipboard: ClipboardState::default(),
1045        }
1046    }
1047
1048    pub fn handle_action(&mut self, action: Action) {
1049        if crate::s3::object::handle_action(self, &action) {
1050            return;
1051        }
1052        if crate::tags::handle_action(self, &action) {
1053            return;
1054        }
1055        if crate::finder::handle_action(self, &action) {
1056            return;
1057        }
1058        if vpc_actions::handle_action(self, &action) || rds_actions::handle_action(self, &action) {
1059            return;
1060        }
1061        match action {
1062            Action::Noop | Action::OpenResourceFinder | Action::OpenRelatedResources => {}
1063            Action::EnterFilterMode => match self.mode {
1064                Mode::ServicePicker => self.service_picker.filter_active = true,
1065                Mode::RegionPicker => self.region_filter_active = true,
1066                Mode::SessionPicker => self.session_filter_active = true,
1067                Mode::ProfilePicker => self.profile_filter_active = true,
1068                _ => {}
1069            },
1070            Action::ExitFilterMode => match self.mode {
1071                Mode::ServicePicker => {
1072                    if self.service_picker.filter_active {
1073                        self.service_picker.filter_active = false;
1074                    } else {
1075                        self.mode = Mode::Normal;
1076                    }
1077                }
1078                Mode::RegionPicker => {
1079                    if self.region_filter_active {
1080                        self.region_filter_active = false;
1081                    } else {
1082                        self.mode = Mode::Normal;
1083                    }
1084                }
1085                Mode::SessionPicker => {
1086                    if self.session_filter_active {
1087                        self.session_filter_active = false;
1088                    } else {
1089                        self.mode = Mode::Normal;
1090                    }
1091                }
1092                Mode::ProfilePicker => {
1093                    if self.profile_filter_active {
1094                        self.profile_filter_active = false;
1095                    } else {
1096                        self.mode = Mode::Normal;
1097                    }
1098                }
1099                _ => {}
1100            },
1101            Action::Quit => {
1102                self.mode = Mode::QuitConfirm;
1103            }
1104            Action::ConfirmQuit => {
1105                self.save_current_session();
1106                self.running = false;
1107            }
1108            Action::CancelQuit => {
1109                self.mode = Mode::Normal;
1110            }
1111            Action::CloseService => {
1112                if !self.tabs.is_empty() {
1113                    // Close the current tab
1114                    self.tabs.remove(self.current_tab);
1115
1116                    if self.tabs.is_empty() {
1117                        // Last tab closed - show service picker
1118                        self.service_selected = false;
1119                        self.current_tab = 0;
1120                        self.mode = Mode::ServicePicker;
1121                    } else {
1122                        // Tabs remain - switch to adjacent tab
1123                        if self.current_tab >= self.tabs.len() {
1124                            self.current_tab = self.tabs.len() - 1;
1125                        }
1126                        self.current_service = self.tabs[self.current_tab].service;
1127                        self.service_selected = true;
1128                        self.mode = Mode::Normal;
1129                    }
1130                } else {
1131                    // No tabs - just close service picker if open
1132                    self.service_selected = false;
1133                    self.mode = Mode::Normal;
1134                }
1135                self.service_picker.filter.clear();
1136                self.service_picker.selected = 0;
1137            }
1138            Action::NextItem => {
1139                // Only navigate when filter is not active
1140                let should_navigate = match self.mode {
1141                    Mode::ServicePicker => !self.service_picker.filter_active,
1142                    Mode::RegionPicker => !self.region_filter_active,
1143                    Mode::SessionPicker => !self.session_filter_active,
1144                    Mode::ProfilePicker => !self.profile_filter_active,
1145                    _ => true,
1146                };
1147                if should_navigate {
1148                    self.next_item();
1149                } else if self.mode == Mode::RegionPicker && self.region_filter_active {
1150                    // In INSERT mode, 'j' goes to filter
1151                    self.region_filter.push('j');
1152                    self.region_picker_selected = 0;
1153                }
1154            }
1155            Action::PrevItem => {
1156                // Only navigate when filter is not active
1157                let should_navigate = match self.mode {
1158                    Mode::ServicePicker => !self.service_picker.filter_active,
1159                    Mode::RegionPicker => !self.region_filter_active,
1160                    Mode::SessionPicker => !self.session_filter_active,
1161                    Mode::ProfilePicker => !self.profile_filter_active,
1162                    _ => true,
1163                };
1164                if should_navigate {
1165                    self.prev_item();
1166                } else if self.mode == Mode::RegionPicker && self.region_filter_active {
1167                    // In INSERT mode, 'k' goes to filter
1168                    self.region_filter.push('k');
1169                    self.region_picker_selected = 0;
1170                }
1171            }
1172            Action::PageUp => self.page_up(),
1173            Action::PageDown => self.page_down(),
1174            Action::NextPane => self.next_pane(),
1175            Action::PrevPane => self.prev_pane(),
1176            Action::CollapseRow => self.collapse_row(),
1177            Action::ExpandRow => self.expand_row(),
1178            Action::Select => self.select_item(),
1179            Action::OpenSpaceMenu => {
1180                // If Insights log group search is focused, Space opens/toggles dropdown
1181                use crate::app::InsightsFocus;
1182                if self.current_service == Service::CloudWatchInsights
1183                    && self.insights_state.insights.insights_focus == InsightsFocus::LogGroupSearch
1184                {
1185                    if self.insights_state.insights.show_dropdown
1186                        && !self.insights_state.insights.log_group_matches.is_empty()
1187                    {
1188                        // Dropdown open: toggle highlighted entry
1189                        self.handle_action(Action::ToggleFilterCheckbox);
1190                    } else {
1191                        // Dropdown closed: open it with all log groups
1192                        self.handle_action(Action::Select);
1193                    }
1194                    return;
1195                }
1196                self.mode = Mode::SpaceMenu;
1197                self.service_picker.filter.clear();
1198                self.service_picker.filter_active = false;
1199                self.service_picker.selected = 0;
1200            }
1201            Action::CloseMenu => {
1202                self.mode = Mode::Normal;
1203                self.service_picker.filter.clear();
1204                // Reset selection when closing filter to avoid out-of-bounds
1205                match self.current_service {
1206                    Service::S3Buckets => {
1207                        crate::s3::actions::reset_on_service_select(self);
1208                    }
1209                    Service::CloudFormationStacks => {
1210                        crate::cfn::actions::apply_filter_reset(self);
1211                    }
1212                    Service::LambdaFunctions => {
1213                        crate::lambda::functions::apply_filter_reset(self);
1214                    }
1215                    Service::SqsQueues => {
1216                        self.sqs_state.queues.reset();
1217                    }
1218                    Service::IamRoles => {
1219                        crate::iam::actions::reset_on_service_select_roles(self);
1220                    }
1221                    Service::IamUsers => {
1222                        crate::iam::actions::reset_on_service_select_users(self);
1223                    }
1224                    Service::IamUserGroups => {
1225                        crate::iam::actions::reset_on_service_select_groups(self);
1226                    }
1227                    Service::CloudWatchAlarms => {
1228                        self.alarms_state.table.reset();
1229                    }
1230                    Service::Ec2Instances => {
1231                        crate::ec2::actions::apply_filter_reset(self);
1232                    }
1233                    Service::EcrRepositories => {
1234                        crate::ecr::actions::apply_filter_reset(self);
1235                    }
1236                    Service::KmsKeys => {
1237                        crate::kms::actions::apply_filter_reset(self);
1238                    }
1239                    Service::EfsFileSystems => {
1240                        crate::efs::actions::apply_filter_reset(self);
1241                    }
1242                    Service::DynamoDbTables => {
1243                        ddb_actions::apply_filter_reset(self);
1244                    }
1245                    Service::RdsInstances => self.rds_state.instances.reset(),
1246                    Service::VpcVpcs => self.vpc_state.vpcs.reset(),
1247                    Service::FsxFileSystems => {
1248                        crate::fsx::actions::apply_filter_reset(self);
1249                    }
1250                    Service::ApiGatewayApis => {
1251                        crate::apig::actions::apply_filter_reset(self);
1252                    }
1253                    Service::LambdaApplications => {
1254                        crate::lambda::applications::apply_filter_reset(self);
1255                    }
1256                    _ => {}
1257                }
1258            }
1259            Action::NextTab => {
1260                if !self.tabs.is_empty() {
1261                    self.current_tab = (self.current_tab + 1) % self.tabs.len();
1262                    self.current_service = self.tabs[self.current_tab].service;
1263                }
1264            }
1265            Action::PrevTab => {
1266                if !self.tabs.is_empty() {
1267                    self.current_tab = if self.current_tab == 0 {
1268                        self.tabs.len() - 1
1269                    } else {
1270                        self.current_tab - 1
1271                    };
1272                    self.current_service = self.tabs[self.current_tab].service;
1273                }
1274            }
1275            Action::CloseTab => {
1276                if !self.tabs.is_empty() {
1277                    self.tabs.remove(self.current_tab);
1278                    if self.tabs.is_empty() {
1279                        // Last tab closed - show service picker
1280                        self.service_selected = false;
1281                        self.current_tab = 0;
1282                        self.service_picker.filter.clear();
1283                        self.service_picker.selected = 0;
1284                        self.mode = Mode::ServicePicker;
1285                    } else {
1286                        // If we closed the last tab, move to the left
1287                        // Otherwise stay at same index (which is now the next tab to the right)
1288                        if self.current_tab >= self.tabs.len() {
1289                            self.current_tab = self.tabs.len() - 1;
1290                        }
1291                        self.current_service = self.tabs[self.current_tab].service;
1292                        self.service_selected = true;
1293                        self.mode = Mode::Normal;
1294                    }
1295                }
1296            }
1297            Action::OpenTabPicker => {
1298                if !self.tabs.is_empty() {
1299                    self.tab_picker_selected = self.current_tab;
1300                    self.mode = Mode::TabPicker;
1301                } else {
1302                    self.mode = Mode::Normal;
1303                }
1304            }
1305            Action::OpenSessionPicker => {
1306                self.save_current_session();
1307                self.sessions = Session::list_all().unwrap_or_default();
1308                self.session_picker_selected = 0;
1309                self.mode = Mode::SessionPicker;
1310            }
1311            Action::LoadSession => {
1312                let filtered_sessions = self.get_filtered_sessions();
1313                if let Some(&session) = filtered_sessions.get(self.session_picker_selected) {
1314                    let session = session.clone();
1315                    // Load the session
1316                    self.profile = session.profile.clone();
1317                    self.region = session.region.clone();
1318                    self.config.account_id = session.account_id.clone();
1319                    self.config.role_arn = session.role_arn.clone();
1320
1321                    // Clear existing tabs and load session tabs
1322                    self.tabs.clear();
1323                    for session_tab in &session.tabs {
1324                        // Parse service from string
1325                        let service = match session_tab.service.as_str() {
1326                            "ApiGatewayApis" => Service::ApiGatewayApis,
1327                            "CloudWatchLogGroups" => Service::CloudWatchLogGroups,
1328                            "CloudWatchInsights" => Service::CloudWatchInsights,
1329                            "CloudWatchAlarms" => Service::CloudWatchAlarms,
1330                            "CloudTrailEvents" => Service::CloudTrailEvents,
1331                            "S3Buckets" => Service::S3Buckets,
1332                            "SqsQueues" => Service::SqsQueues,
1333                            "Ec2Instances" => Service::Ec2Instances,
1334                            "EcrRepositories" => Service::EcrRepositories,
1335                            "KmsKeys" => Service::KmsKeys,
1336                            "EfsFileSystems" => Service::EfsFileSystems,
1337                            "FsxFileSystems" => Service::FsxFileSystems,
1338                            "RdsInstances" => Service::RdsInstances,
1339                            "VpcVpcs" => Service::VpcVpcs,
1340                            "DynamoDbTables" => Service::DynamoDbTables,
1341                            "LambdaFunctions" => Service::LambdaFunctions,
1342                            "LambdaApplications" => Service::LambdaApplications,
1343                            "CloudFormationStacks" => Service::CloudFormationStacks,
1344                            "IamUsers" => Service::IamUsers,
1345                            "IamRoles" => Service::IamRoles,
1346                            "IamUserGroups" => Service::IamUserGroups,
1347                            _ => continue,
1348                        };
1349
1350                        self.tabs.push(Tab {
1351                            service,
1352                            title: if service == Service::RdsInstances {
1353                                service.name().into()
1354                            } else {
1355                                session_tab.title.clone()
1356                            },
1357                            breadcrumb: if service == Service::RdsInstances {
1358                                service.name().into()
1359                            } else {
1360                                session_tab.breadcrumb.clone()
1361                            },
1362                        });
1363
1364                        // Restore filter if present
1365                        if let Some(filter) = &session_tab.filter {
1366                            if service == Service::CloudWatchLogGroups {
1367                                self.log_groups_state.log_groups.filter = filter.clone();
1368                            }
1369                        }
1370                    }
1371
1372                    if !self.tabs.is_empty() {
1373                        self.current_tab = 0;
1374                        self.current_service = self.tabs[0].service;
1375                        self.service_selected = true;
1376                        self.current_session = Some(session.clone());
1377                    }
1378                }
1379                self.mode = Mode::Normal;
1380            }
1381            Action::SaveSession => {
1382                // TODO: Implement session saving
1383            }
1384            Action::OpenServicePicker => {
1385                if self.mode == Mode::ServicePicker {
1386                    self.tabs.push(Tab {
1387                        service: Service::S3Buckets,
1388                        title: "S3 › Buckets".to_string(),
1389                        breadcrumb: "S3 › Buckets".to_string(),
1390                    });
1391                    self.current_tab = self.tabs.len() - 1;
1392                    self.current_service = Service::S3Buckets;
1393                    self.view_mode = ViewMode::List;
1394                    self.service_selected = true;
1395                    self.mode = Mode::Normal;
1396                } else {
1397                    self.mode = Mode::ServicePicker;
1398                    self.service_picker.filter.clear();
1399                    self.service_picker.selected = 0;
1400                }
1401            }
1402            Action::OpenCloudWatch => {
1403                self.current_service = Service::CloudWatchLogGroups;
1404                self.view_mode = ViewMode::List;
1405                self.service_selected = true;
1406                self.mode = Mode::Normal;
1407            }
1408            Action::OpenCloudWatchSplit => {
1409                self.current_service = Service::CloudWatchInsights;
1410                self.view_mode = ViewMode::InsightsResults;
1411                self.service_selected = true;
1412                self.mode = Mode::Normal;
1413            }
1414            Action::OpenCloudWatchAlarms => {
1415                self.current_service = Service::CloudWatchAlarms;
1416                self.view_mode = ViewMode::List;
1417                self.service_selected = true;
1418                self.mode = Mode::Normal;
1419            }
1420            Action::FilterInput(c) => {
1421                if self.mode == Mode::TabPicker {
1422                    self.tab_filter.push(c);
1423                    self.tab_picker_selected = 0;
1424                } else if self.mode == Mode::ServicePicker && self.service_picker.filter_active {
1425                    self.service_picker.filter.push(c);
1426                    self.service_picker.selected = 0;
1427                } else if self.mode == Mode::ServicePicker && c == 'q' {
1428                    self.mode = Mode::QuitConfirm;
1429                } else if self.mode == Mode::ServicePicker && c == 'i' {
1430                    self.service_picker.filter_active = true;
1431                } else if self.mode == Mode::ServicePicker {
1432                    // Any other char auto-activates filter and types it
1433                    self.service_picker.filter_active = true;
1434                    self.service_picker.filter.push(c);
1435                    self.service_picker.selected = 0;
1436                } else if self.mode == Mode::RegionPicker && self.region_filter_active {
1437                    self.region_filter.push(c);
1438                    self.region_picker_selected = 0;
1439                } else if self.mode == Mode::ProfilePicker && self.profile_filter_active {
1440                    self.profile_filter.push(c);
1441                    self.profile_picker_selected = 0;
1442                } else if self.mode == Mode::SessionPicker && self.session_filter_active {
1443                    self.session_filter.push(c);
1444                    self.session_picker_selected = 0;
1445                } else if self.mode == Mode::InsightsInput {
1446                    match self.insights_state.insights.insights_focus {
1447                        InsightsFocus::Query => {
1448                            self.insights_state.insights.query_text.push(c);
1449                        }
1450                        InsightsFocus::LogGroupSearch => {
1451                            self.insights_state.insights.log_group_search.push(c);
1452                            // Update matches
1453                            if !self.insights_state.insights.log_group_search.is_empty() {
1454                                self.insights_state.insights.log_group_matches = self
1455                                    .log_groups_state
1456                                    .log_groups
1457                                    .items
1458                                    .iter()
1459                                    .filter(|g| {
1460                                        g.name.to_lowercase().contains(
1461                                            &self
1462                                                .insights_state
1463                                                .insights
1464                                                .log_group_search
1465                                                .to_lowercase(),
1466                                        )
1467                                    })
1468                                    .take(50)
1469                                    .map(|g| g.name.clone())
1470                                    .collect();
1471                                self.insights_state.insights.show_dropdown = true;
1472                            } else {
1473                                self.insights_state.insights.log_group_matches.clear();
1474                                self.insights_state.insights.show_dropdown = false;
1475                            }
1476                        }
1477                        _ => {}
1478                    }
1479                } else if self.mode == Mode::FilterInput {
1480                    // Check if we should capture digits for page navigation
1481                    let is_pagination_focused = if self.current_service
1482                        == Service::LambdaApplications
1483                    {
1484                        crate::lambda::applications::is_pagination_focused(self)
1485                    } else if self.current_service == Service::CloudFormationStacks {
1486                        crate::cfn::actions::is_pagination_focused(self)
1487                    } else if self.current_service == Service::IamRoles
1488                        && self.iam_state.current_role.is_none()
1489                    {
1490                        crate::iam::actions::is_pagination_focused_roles(self)
1491                    } else if self.view_mode == ViewMode::PolicyView {
1492                        crate::iam::actions::is_pagination_focused_policy_view(self)
1493                    } else if self.current_service == Service::CloudWatchAlarms {
1494                        crate::cw::actions::alarms_is_pagination_focused(self)
1495                    } else if self.current_service == Service::CloudTrailEvents {
1496                        crate::cloudtrail::actions::is_pagination_focused(self)
1497                    } else if self.current_service == Service::Ec2Instances {
1498                        crate::ec2::actions::is_pagination_focused(self)
1499                    } else if self.current_service == Service::CloudWatchLogGroups {
1500                        crate::cw::actions::logs_is_pagination_focused(self)
1501                    } else if self.current_service == Service::ApiGatewayApis {
1502                        crate::apig::actions::is_pagination_focused(self)
1503                    } else if self.current_service == Service::EcrRepositories
1504                        && self.ecr_state.current_repository.is_none()
1505                    {
1506                        crate::ecr::actions::is_pagination_focused(self)
1507                    } else if self.current_service == Service::KmsKeys {
1508                        crate::kms::actions::is_pagination_focused(self)
1509                    } else if self.current_service == Service::EfsFileSystems {
1510                        crate::efs::actions::is_pagination_focused(self)
1511                    } else if self.current_service == Service::DynamoDbTables {
1512                        ddb_actions::is_pagination_focused(self)
1513                    } else if self.current_service == Service::FsxFileSystems {
1514                        crate::fsx::actions::is_pagination_focused(self)
1515                    } else if self.current_service == Service::LambdaFunctions {
1516                        crate::lambda::functions::is_pagination_focused(self)
1517                    } else if self.current_service == Service::SqsQueues {
1518                        if self.sqs_state.current_queue.is_some()
1519                            && (self.sqs_state.detail_tab == SqsQueueDetailTab::LambdaTriggers
1520                                || self.sqs_state.detail_tab == SqsQueueDetailTab::EventBridgePipes
1521                                || self.sqs_state.detail_tab == SqsQueueDetailTab::Tagging
1522                                || self.sqs_state.detail_tab == SqsQueueDetailTab::Encryption
1523                                || self.sqs_state.detail_tab == SqsQueueDetailTab::SnsSubscriptions)
1524                        {
1525                            self.sqs_state.input_focus == InputFocus::Pagination
1526                        } else {
1527                            false
1528                        }
1529                    } else {
1530                        false
1531                    };
1532
1533                    if is_pagination_focused && c.is_ascii_digit() {
1534                        self.page_input.push(c);
1535                    } else if self.current_service == Service::LambdaApplications {
1536                        if crate::lambda::applications::is_filter_focused(self) {
1537                            self.apply_filter_operation(|f| f.push(c));
1538                        }
1539                    } else if self.current_service == Service::CloudFormationStacks {
1540                        crate::cfn::actions::filter_char_push(self, c);
1541                    } else if self.current_service == Service::EcrRepositories
1542                        && self.ecr_state.current_repository.is_none()
1543                    {
1544                        if self.ecr_state.input_focus == InputFocus::Filter {
1545                            self.apply_filter_operation(|f| f.push(c));
1546                        }
1547                    } else if self.current_service == Service::IamRoles
1548                        && self.iam_state.current_role.is_none()
1549                    {
1550                        if crate::iam::actions::is_filter_focused_roles(self) {
1551                            self.apply_filter_operation(|f| f.push(c));
1552                        }
1553                    } else if self.view_mode == ViewMode::PolicyView {
1554                        if crate::iam::actions::is_filter_focused_policy_view(self) {
1555                            self.apply_filter_operation(|f| f.push(c));
1556                        }
1557                    } else if self.current_service == Service::ApiGatewayApis
1558                        && self.apig_state.current_api.is_some()
1559                        && self.apig_state.detail_tab == crate::ui::apig::ApiDetailTab::Routes
1560                    {
1561                        if crate::apig::actions::filter_char_allowed(self) {
1562                            self.apply_filter_operation(|f| f.push(c));
1563                        }
1564                    } else if self.current_service == Service::LambdaFunctions {
1565                        if crate::lambda::functions::filter_char_allowed(self) {
1566                            self.apply_filter_operation(|f| f.push(c));
1567                        }
1568                    } else if self.current_service == Service::SqsQueues
1569                        && self.sqs_state.current_queue.is_some()
1570                        && (self.sqs_state.detail_tab == SqsQueueDetailTab::LambdaTriggers
1571                            || self.sqs_state.detail_tab == SqsQueueDetailTab::EventBridgePipes
1572                            || self.sqs_state.detail_tab == SqsQueueDetailTab::Tagging
1573                            || self.sqs_state.detail_tab == SqsQueueDetailTab::Encryption
1574                            || self.sqs_state.detail_tab == SqsQueueDetailTab::SnsSubscriptions)
1575                    {
1576                        if self.sqs_state.input_focus == InputFocus::Filter {
1577                            self.apply_filter_operation(|f| f.push(c));
1578                        }
1579                    } else if self.current_service == Service::Ec2Instances
1580                        && self.ec2_state.current_instance.is_some()
1581                        && self.ec2_state.detail_tab == Ec2DetailTab::Tags
1582                    {
1583                        crate::ec2::actions::filter_char_push(self, c);
1584                    } else if self.current_service == Service::CloudWatchLogGroups {
1585                        if self.log_groups_state.input_focus == InputFocus::Filter {
1586                            self.apply_filter_operation(|f| f.push(c));
1587                        }
1588                    } else {
1589                        self.apply_filter_operation(|f| f.push(c));
1590                    }
1591                } else if self.mode == Mode::EventFilterInput {
1592                    if self.log_groups_state.event_input_focus == EventFilterFocus::Filter {
1593                        self.log_groups_state.event_filter.push(c);
1594                    } else if c.is_ascii_digit() {
1595                        self.log_groups_state.relative_amount.push(c);
1596                    }
1597                } else if self.mode == Mode::Normal && c.is_ascii_digit() {
1598                    self.page_input.push(c);
1599                }
1600            }
1601            Action::FilterBackspace => {
1602                if self.mode == Mode::ServicePicker && self.service_picker.filter_active {
1603                    self.service_picker.filter.pop();
1604                    self.service_picker.selected = 0;
1605                } else if self.mode == Mode::TabPicker {
1606                    self.tab_filter.pop();
1607                    self.tab_picker_selected = 0;
1608                } else if self.mode == Mode::RegionPicker && self.region_filter_active {
1609                    self.region_filter.pop();
1610                    self.region_picker_selected = 0;
1611                } else if self.mode == Mode::ProfilePicker && self.profile_filter_active {
1612                    self.profile_filter.pop();
1613                    self.profile_picker_selected = 0;
1614                } else if self.mode == Mode::SessionPicker && self.session_filter_active {
1615                    self.session_filter.pop();
1616                    self.session_picker_selected = 0;
1617                } else if self.mode == Mode::InsightsInput {
1618                    match self.insights_state.insights.insights_focus {
1619                        InsightsFocus::Query => {
1620                            self.insights_state.insights.query_text.pop();
1621                        }
1622                        InsightsFocus::LogGroupSearch => {
1623                            self.insights_state.insights.log_group_search.pop();
1624                            // Update matches
1625                            if !self.insights_state.insights.log_group_search.is_empty() {
1626                                self.insights_state.insights.log_group_matches = self
1627                                    .log_groups_state
1628                                    .log_groups
1629                                    .items
1630                                    .iter()
1631                                    .filter(|g| {
1632                                        g.name.to_lowercase().contains(
1633                                            &self
1634                                                .insights_state
1635                                                .insights
1636                                                .log_group_search
1637                                                .to_lowercase(),
1638                                        )
1639                                    })
1640                                    .take(50)
1641                                    .map(|g| g.name.clone())
1642                                    .collect();
1643                                self.insights_state.insights.show_dropdown = true;
1644                            } else {
1645                                self.insights_state.insights.log_group_matches.clear();
1646                                self.insights_state.insights.show_dropdown = false;
1647                            }
1648                        }
1649                        _ => {}
1650                    }
1651                } else if self.mode == Mode::FilterInput {
1652                    // Only allow backspace when focus is on the input field
1653                    if self.current_service == Service::CloudFormationStacks {
1654                        if self.cfn_state.current_stack.is_some()
1655                            && self.cfn_state.detail_tab == CfnDetailTab::Parameters
1656                        {
1657                            if self.cfn_state.parameters_input_focus == InputFocus::Filter {
1658                                self.cfn_state.parameters.filter.pop();
1659                                self.cfn_state.parameters.selected = 0;
1660                            }
1661                        } else if self.cfn_state.current_stack.is_some()
1662                            && self.cfn_state.detail_tab == CfnDetailTab::Outputs
1663                        {
1664                            if self.cfn_state.outputs_input_focus == InputFocus::Filter {
1665                                self.cfn_state.outputs.filter.pop();
1666                                self.cfn_state.outputs.selected = 0;
1667                            }
1668                        } else if self.cfn_state.current_stack.is_some()
1669                            && self.cfn_state.detail_tab == CfnDetailTab::Resources
1670                        {
1671                            if self.cfn_state.resources_input_focus == InputFocus::Filter {
1672                                self.cfn_state.resources.filter.pop();
1673                                self.cfn_state.resources.selected = 0;
1674                            }
1675                        } else if self.cfn_state.input_focus == InputFocus::Filter {
1676                            self.apply_filter_operation(|f| {
1677                                f.pop();
1678                            });
1679                        }
1680                    } else if self.current_service == Service::Ec2Instances
1681                        && self.ec2_state.current_instance.is_some()
1682                        && self.ec2_state.detail_tab == Ec2DetailTab::Tags
1683                    {
1684                        crate::ec2::actions::filter_char_pop(self);
1685                    } else if self.current_service == Service::CloudWatchLogGroups {
1686                        if self.log_groups_state.input_focus == InputFocus::Filter {
1687                            self.apply_filter_operation(|f| {
1688                                f.pop();
1689                            });
1690                        }
1691                    } else {
1692                        self.apply_filter_operation(|f| {
1693                            f.pop();
1694                        });
1695                    }
1696                } else if self.mode == Mode::EventFilterInput {
1697                    if self.log_groups_state.event_input_focus == EventFilterFocus::Filter {
1698                        self.log_groups_state.event_filter.pop();
1699                    } else {
1700                        self.log_groups_state.relative_amount.pop();
1701                    }
1702                }
1703            }
1704            Action::DeleteWord => {
1705                let text = if self.mode == Mode::ServicePicker {
1706                    &mut self.service_picker.filter
1707                } else if self.mode == Mode::InsightsInput {
1708                    use crate::app::InsightsFocus;
1709                    match self.insights_state.insights.insights_focus {
1710                        InsightsFocus::Query => &mut self.insights_state.insights.query_text,
1711                        InsightsFocus::LogGroupSearch => {
1712                            &mut self.insights_state.insights.log_group_search
1713                        }
1714                        _ => return,
1715                    }
1716                } else if self.mode == Mode::FilterInput {
1717                    if let Some(filter) = self.get_active_filter_mut() {
1718                        filter
1719                    } else {
1720                        return;
1721                    }
1722                } else if self.mode == Mode::EventFilterInput {
1723                    if self.log_groups_state.event_input_focus == EventFilterFocus::Filter {
1724                        &mut self.log_groups_state.event_filter
1725                    } else {
1726                        &mut self.log_groups_state.relative_amount
1727                    }
1728                } else {
1729                    return;
1730                };
1731
1732                if text.is_empty() {
1733                    return;
1734                }
1735
1736                let mut chars: Vec<char> = text.chars().collect();
1737                while !chars.is_empty() && chars.last().is_some_and(|c| c.is_whitespace()) {
1738                    chars.pop();
1739                }
1740                while !chars.is_empty() && !chars.last().is_some_and(|c| c.is_whitespace()) {
1741                    chars.pop();
1742                }
1743                *text = chars.into_iter().collect();
1744            }
1745            Action::WordLeft => {
1746                // Not implemented - would need cursor position tracking
1747            }
1748            Action::WordRight => {
1749                // Not implemented - would need cursor position tracking
1750            }
1751            Action::OpenColumnSelector => {
1752                // Block preferences for CFN non-column tabs
1753                if self.current_service == Service::CloudFormationStacks
1754                    && crate::cfn::actions::block_column_selector(self)
1755                {
1756                    return;
1757                }
1758
1759                // Don't allow opening preferences for IAM user tabs without preferences
1760                if self.current_service == Service::IamUsers
1761                    && self.iam_state.current_user.is_some()
1762                    && self.iam_state.user_tab == UserTab::SecurityCredentials
1763                {
1764                    return;
1765                }
1766
1767                // Don't allow opening preferences for IAM role tabs without preferences
1768                if self.current_service == Service::IamRoles
1769                    && self.iam_state.current_role.is_some()
1770                    && (self.iam_state.role_tab == RoleTab::TrustRelationships
1771                        || self.iam_state.role_tab == RoleTab::RevokeSessions)
1772                {
1773                    return;
1774                }
1775
1776                // Don't allow opening preferences for certain SQS tabs
1777                if self.current_service == Service::SqsQueues
1778                    && self.sqs_state.current_queue.is_some()
1779                    && matches!(
1780                        self.sqs_state.detail_tab,
1781                        SqsQueueDetailTab::QueuePolicies
1782                            | SqsQueueDetailTab::Monitoring
1783                            | SqsQueueDetailTab::DeadLetterQueue
1784                            | SqsQueueDetailTab::Encryption
1785                            | SqsQueueDetailTab::DeadLetterQueueRedriveTasks
1786                    )
1787                {
1788                    return;
1789                }
1790
1791                // Block preferences for EC2 non-tag tabs
1792                if self.current_service == Service::Ec2Instances
1793                    && crate::ec2::actions::block_column_selector(self)
1794                {
1795                    return;
1796                }
1797
1798                // Don't allow opening preferences for CloudTrail Event JSON
1799                if self.current_service == Service::CloudTrailEvents
1800                    && self.cloudtrail_state.current_event.is_some()
1801                    && self.cloudtrail_state.detail_focus == CloudTrailDetailFocus::EventRecord
1802                {
1803                    return;
1804                }
1805
1806                // If we have page input, apply it instead of opening column selector
1807                if !self.page_input.is_empty() {
1808                    if let Ok(page) = self.page_input.parse::<usize>() {
1809                        self.go_to_page(page);
1810                    }
1811                    self.page_input.clear();
1812                } else {
1813                    self.mode = Mode::ColumnSelector;
1814                    self.column_selector_index = 0;
1815                }
1816            }
1817            Action::ToggleColumn => {
1818                crate::preferences::bindings::activate_active(self);
1819            }
1820            Action::NextPreferences => {
1821                crate::preferences::bindings::move_section_active(self, true);
1822            }
1823            Action::PrevPreferences => {
1824                crate::preferences::bindings::move_section_active(self, false);
1825            }
1826            Action::CloseColumnSelector => {
1827                self.mode = Mode::Normal;
1828                self.preference_section = Preferences::Columns;
1829            }
1830            Action::NextDetailTab => {
1831                if self.current_service == Service::CloudTrailEvents
1832                    && self.cloudtrail_state.current_event.is_some()
1833                {
1834                    crate::cloudtrail::actions::next_detail_tab(self);
1835                } else if self.current_service == Service::ApiGatewayApis
1836                    && self.apig_state.current_api.is_some()
1837                {
1838                    crate::apig::actions::next_detail_tab(self);
1839                } else if self.current_service == Service::SqsQueues
1840                    && self.sqs_state.current_queue.is_some()
1841                {
1842                    crate::sqs::actions::next_detail_tab(self);
1843                } else if self.current_service == Service::Ec2Instances
1844                    && self.ec2_state.current_instance.is_some()
1845                {
1846                    crate::ec2::actions::next_detail_tab(self);
1847                } else if self.current_service == Service::LambdaApplications
1848                    && self.lambda_application_state.current_application.is_some()
1849                {
1850                    crate::lambda::applications::next_detail_tab(self);
1851                } else if self.current_service == Service::IamRoles
1852                    && self.iam_state.current_role.is_some()
1853                {
1854                    crate::iam::actions::next_detail_tab_roles(self);
1855                } else if self.current_service == Service::IamUsers
1856                    && self.iam_state.current_user.is_some()
1857                {
1858                    crate::iam::actions::next_detail_tab_users(self);
1859                } else if self.current_service == Service::IamUserGroups
1860                    && self.iam_state.current_group.is_some()
1861                {
1862                    crate::iam::actions::next_detail_tab_groups(self);
1863                } else if self.current_service == Service::CloudWatchAlarms {
1864                    crate::cw::actions::alarms_next_detail_tab(self);
1865                } else if self.view_mode == ViewMode::Detail {
1866                    self.log_groups_state.detail_tab = self.log_groups_state.detail_tab.next();
1867                    if self.log_groups_state.detail_tab == DetailTab::Tags {
1868                        self.log_groups_state.tags.loading = true;
1869                    }
1870                } else if self.current_service == Service::S3Buckets {
1871                    crate::s3::actions::next_detail_tab(self);
1872                } else if self.current_service == Service::EcrRepositories
1873                    && self.ecr_state.current_repository.is_none()
1874                {
1875                    crate::ecr::actions::next_detail_tab(self);
1876                } else if self.current_service == Service::KmsKeys {
1877                    crate::kms::actions::next_detail_tab(self);
1878                } else if self.current_service == Service::EfsFileSystems
1879                    && self.efs_state.current_file_system.is_some()
1880                {
1881                    crate::efs::actions::next_detail_tab(self);
1882                } else if self.current_service == Service::DynamoDbTables
1883                    && self.dynamodb_state.current_table.is_some()
1884                {
1885                    ddb_actions::next_detail_tab(self);
1886                } else if self.current_service == Service::FsxFileSystems
1887                    && self.fsx_state.current_file_system.is_some()
1888                {
1889                    crate::fsx::actions::next_detail_tab(self);
1890                } else if self.current_service == Service::LambdaFunctions
1891                    && self.lambda_state.current_function.is_some()
1892                {
1893                    crate::lambda::functions::next_detail_tab(self);
1894                } else if self.current_service == Service::CloudFormationStacks
1895                    && self.cfn_state.current_stack.is_some()
1896                {
1897                    crate::cfn::actions::next_detail_tab(self);
1898                }
1899            }
1900            Action::PrevDetailTab => {
1901                if self.current_service == Service::CloudTrailEvents
1902                    && self.cloudtrail_state.current_event.is_some()
1903                {
1904                    crate::cloudtrail::actions::prev_detail_tab(self);
1905                } else if self.current_service == Service::ApiGatewayApis
1906                    && self.apig_state.current_api.is_some()
1907                {
1908                    crate::apig::actions::prev_detail_tab(self);
1909                } else if self.current_service == Service::SqsQueues
1910                    && self.sqs_state.current_queue.is_some()
1911                {
1912                    crate::sqs::actions::prev_detail_tab(self);
1913                } else if self.current_service == Service::Ec2Instances
1914                    && self.ec2_state.current_instance.is_some()
1915                {
1916                    crate::ec2::actions::prev_detail_tab(self);
1917                } else if self.current_service == Service::LambdaApplications
1918                    && self.lambda_application_state.current_application.is_some()
1919                {
1920                    crate::lambda::applications::prev_detail_tab(self);
1921                } else if self.current_service == Service::IamRoles
1922                    && self.iam_state.current_role.is_some()
1923                {
1924                    crate::iam::actions::prev_detail_tab_roles(self);
1925                } else if self.current_service == Service::IamUsers
1926                    && self.iam_state.current_user.is_some()
1927                {
1928                    crate::iam::actions::prev_detail_tab_users(self);
1929                } else if self.current_service == Service::IamUserGroups
1930                    && self.iam_state.current_group.is_some()
1931                {
1932                    crate::iam::actions::prev_detail_tab_groups(self);
1933                } else if self.current_service == Service::CloudWatchAlarms {
1934                    crate::cw::actions::alarms_prev_detail_tab(self);
1935                } else if self.view_mode == ViewMode::Detail {
1936                    self.log_groups_state.detail_tab = self.log_groups_state.detail_tab.prev();
1937                } else if self.current_service == Service::S3Buckets {
1938                    crate::s3::actions::prev_detail_tab(self);
1939                } else if self.current_service == Service::EcrRepositories
1940                    && self.ecr_state.current_repository.is_none()
1941                {
1942                    crate::ecr::actions::prev_detail_tab(self);
1943                } else if self.current_service == Service::KmsKeys {
1944                    crate::kms::actions::prev_detail_tab(self);
1945                } else if self.current_service == Service::EfsFileSystems
1946                    && self.efs_state.current_file_system.is_some()
1947                {
1948                    crate::efs::actions::prev_detail_tab(self);
1949                } else if self.current_service == Service::DynamoDbTables
1950                    && self.dynamodb_state.current_table.is_some()
1951                {
1952                    ddb_actions::prev_detail_tab(self);
1953                } else if self.current_service == Service::FsxFileSystems
1954                    && self.fsx_state.current_file_system.is_some()
1955                {
1956                    crate::fsx::actions::prev_detail_tab(self);
1957                } else if self.current_service == Service::LambdaFunctions
1958                    && self.lambda_state.current_function.is_some()
1959                {
1960                    crate::lambda::functions::prev_detail_tab(self);
1961                } else if self.current_service == Service::CloudFormationStacks
1962                    && self.cfn_state.current_stack.is_some()
1963                {
1964                    crate::cfn::actions::prev_detail_tab(self);
1965                }
1966            }
1967            Action::StartFilter => {
1968                // Don't allow filter mode when no service is selected and no tabs open
1969                if !self.service_selected && self.tabs.is_empty() {
1970                    return;
1971                }
1972
1973                if self.current_service == Service::CloudWatchInsights {
1974                    self.mode = Mode::InsightsInput;
1975                } else if self.current_service == Service::CloudWatchAlarms {
1976                    self.mode = Mode::FilterInput;
1977                } else if self.current_service == Service::CloudTrailEvents {
1978                    self.mode = Mode::FilterInput;
1979                    self.cloudtrail_state.input_focus = InputFocus::Filter;
1980                } else if self.current_service == Service::S3Buckets {
1981                    self.mode = Mode::FilterInput;
1982                    crate::s3::actions::start_filter(self);
1983                } else if self.current_service == Service::ApiGatewayApis
1984                    || self.current_service == Service::EcrRepositories
1985                    || self.current_service == Service::KmsKeys
1986                    || self.current_service == Service::EfsFileSystems
1987                    || self.current_service == Service::FsxFileSystems
1988                    || self.current_service == Service::DynamoDbTables
1989                    || self.current_service == Service::IamUsers
1990                    || self.current_service == Service::IamUserGroups
1991                {
1992                    self.mode = Mode::FilterInput;
1993                    if self.current_service == Service::ApiGatewayApis {
1994                        crate::apig::actions::start_filter(self);
1995                    } else if self.current_service == Service::EcrRepositories
1996                        && self.ecr_state.current_repository.is_none()
1997                    {
1998                        self.ecr_state.input_focus = InputFocus::Filter;
1999                    } else if self.current_service == Service::KmsKeys {
2000                        crate::kms::actions::start_filter(self);
2001                    } else if self.current_service == Service::EfsFileSystems {
2002                        crate::efs::actions::start_filter(self);
2003                    } else if self.current_service == Service::DynamoDbTables {
2004                        ddb_actions::start_filter(self)
2005                    } else if self.current_service == Service::FsxFileSystems {
2006                        crate::fsx::actions::start_filter(self);
2007                    }
2008                } else if self.current_service == Service::LambdaFunctions {
2009                    self.mode = Mode::FilterInput;
2010                    crate::lambda::functions::start_filter(self);
2011                } else if self.current_service == Service::LambdaApplications {
2012                    self.mode = Mode::FilterInput;
2013                    crate::lambda::applications::start_filter(self);
2014                } else if self.current_service == Service::IamRoles {
2015                    self.mode = Mode::FilterInput;
2016                } else if self.current_service == Service::CloudFormationStacks {
2017                    self.mode = Mode::FilterInput;
2018                    crate::cfn::actions::start_filter(self);
2019                } else if self.current_service == Service::SqsQueues {
2020                    self.mode = Mode::FilterInput;
2021                    self.sqs_state.input_focus = InputFocus::Filter;
2022                } else if self.view_mode == ViewMode::List
2023                    || (self.view_mode == ViewMode::Detail
2024                        && (self.log_groups_state.detail_tab == DetailTab::LogStreams
2025                            || self.log_groups_state.detail_tab == DetailTab::Tags))
2026                {
2027                    self.mode = Mode::FilterInput;
2028                    self.log_groups_state.filter_mode = true;
2029                    self.log_groups_state.input_focus = InputFocus::Filter;
2030                } else if self.view_mode == ViewMode::Events {
2031                    self.mode = Mode::EventFilterInput;
2032                }
2033            }
2034            Action::StartEventFilter => {
2035                if self.current_service == Service::CloudWatchInsights {
2036                    self.mode = Mode::InsightsInput;
2037                } else if self.view_mode == ViewMode::List {
2038                    self.mode = Mode::FilterInput;
2039                    self.log_groups_state.filter_mode = true;
2040                    self.log_groups_state.input_focus = InputFocus::Filter;
2041                } else if self.view_mode == ViewMode::Events {
2042                    self.mode = Mode::EventFilterInput;
2043                } else if self.view_mode == ViewMode::Detail
2044                    && self.log_groups_state.detail_tab == DetailTab::LogStreams
2045                {
2046                    self.mode = Mode::FilterInput;
2047                    self.log_groups_state.filter_mode = true;
2048                    self.log_groups_state.input_focus = InputFocus::Filter;
2049                }
2050            }
2051            Action::NextFilterFocus => {
2052                if self.current_service == Service::CloudTrailEvents
2053                    && self.cloudtrail_state.current_event.is_some()
2054                {
2055                    self.cloudtrail_state.detail_focus = self.cloudtrail_state.detail_focus.next();
2056                } else if self.mode == Mode::FilterInput
2057                    && self.current_service == Service::S3Buckets
2058                {
2059                    crate::s3::actions::next_filter_focus(self);
2060                } else if self.mode == Mode::FilterInput
2061                    && self.current_service == Service::Ec2Instances
2062                {
2063                    crate::ec2::actions::next_filter_focus(self);
2064                } else if self.mode == Mode::FilterInput
2065                    && self.current_service == Service::LambdaApplications
2066                {
2067                    crate::lambda::applications::next_filter_focus(self);
2068                } else if self.mode == Mode::FilterInput
2069                    && self.current_service == Service::IamRoles
2070                {
2071                    crate::iam::actions::next_filter_focus_roles(self);
2072                } else if self.mode == Mode::FilterInput
2073                    && self.current_service == Service::IamUsers
2074                    && self.iam_state.current_user.is_some()
2075                {
2076                    crate::iam::actions::next_filter_focus_users(self);
2077                } else if self.mode == Mode::FilterInput
2078                    && self.current_service == Service::IamUserGroups
2079                {
2080                    crate::iam::actions::next_filter_focus_groups(self);
2081                } else if self.mode == Mode::InsightsInput {
2082                    use crate::app::InsightsFocus;
2083                    self.insights_state.insights.insights_focus =
2084                        match self.insights_state.insights.insights_focus {
2085                            InsightsFocus::QueryLanguage => InsightsFocus::DatePicker,
2086                            InsightsFocus::DatePicker => InsightsFocus::LogGroupSearch,
2087                            InsightsFocus::LogGroupSearch => InsightsFocus::Query,
2088                            InsightsFocus::Query => InsightsFocus::QueryLanguage,
2089                        };
2090                } else if self.mode == Mode::FilterInput
2091                    && self.current_service == Service::CloudFormationStacks
2092                {
2093                    crate::cfn::actions::next_filter_focus(self);
2094                } else if self.mode == Mode::FilterInput
2095                    && self.current_service == Service::SqsQueues
2096                {
2097                    crate::sqs::actions::next_filter_focus(self);
2098                } else if self.mode == Mode::FilterInput
2099                    && self.current_service == Service::CloudWatchLogGroups
2100                {
2101                    crate::cw::actions::logs_next_filter_focus(self);
2102                } else if self.mode == Mode::EventFilterInput {
2103                    crate::cw::actions::logs_next_event_filter_focus(self);
2104                } else if self.mode == Mode::FilterInput
2105                    && self.current_service == Service::CloudWatchAlarms
2106                {
2107                    crate::cw::actions::alarms_next_filter_focus(self);
2108                } else if self.mode == Mode::FilterInput
2109                    && self.current_service == Service::CloudTrailEvents
2110                {
2111                    crate::cloudtrail::actions::next_filter_focus(self);
2112                } else if self.mode == Mode::FilterInput
2113                    && self.current_service == Service::ApiGatewayApis
2114                {
2115                    crate::apig::actions::next_filter_focus(self);
2116                } else if self.mode == Mode::FilterInput
2117                    && self.current_service == Service::EcrRepositories
2118                    && self.ecr_state.current_repository.is_none()
2119                {
2120                    crate::ecr::actions::next_filter_focus(self);
2121                } else if self.mode == Mode::FilterInput && self.current_service == Service::KmsKeys
2122                {
2123                    crate::kms::actions::next_filter_focus(self);
2124                } else if self.mode == Mode::FilterInput
2125                    && self.current_service == Service::EfsFileSystems
2126                {
2127                    crate::efs::actions::next_filter_focus(self);
2128                } else if self.mode == Mode::FilterInput
2129                    && self.current_service == Service::DynamoDbTables
2130                {
2131                    ddb_actions::next_filter_focus(self);
2132                } else if self.mode == Mode::FilterInput
2133                    && self.current_service == Service::FsxFileSystems
2134                {
2135                    crate::fsx::actions::next_filter_focus(self);
2136                } else if self.mode == Mode::FilterInput
2137                    && self.current_service == Service::LambdaFunctions
2138                {
2139                    crate::lambda::functions::next_filter_focus(self);
2140                }
2141            }
2142            Action::PrevFilterFocus => {
2143                if self.current_service == Service::CloudTrailEvents
2144                    && self.cloudtrail_state.current_event.is_some()
2145                {
2146                    self.cloudtrail_state.detail_focus = self.cloudtrail_state.detail_focus.prev();
2147                } else if self.mode == Mode::FilterInput
2148                    && self.current_service == Service::ApiGatewayApis
2149                {
2150                    crate::apig::actions::prev_filter_focus(self);
2151                } else if self.mode == Mode::FilterInput
2152                    && self.current_service == Service::S3Buckets
2153                {
2154                    crate::s3::actions::prev_filter_focus(self);
2155                } else if self.mode == Mode::FilterInput
2156                    && self.current_service == Service::Ec2Instances
2157                {
2158                    crate::ec2::actions::prev_filter_focus(self);
2159                } else if self.mode == Mode::FilterInput
2160                    && self.current_service == Service::LambdaApplications
2161                {
2162                    crate::lambda::applications::prev_filter_focus(self);
2163                } else if self.mode == Mode::FilterInput
2164                    && self.current_service == Service::CloudFormationStacks
2165                {
2166                    crate::cfn::actions::prev_filter_focus(self);
2167                } else if self.mode == Mode::FilterInput
2168                    && self.current_service == Service::SqsQueues
2169                {
2170                    crate::sqs::actions::prev_filter_focus(self);
2171                } else if self.mode == Mode::FilterInput
2172                    && self.current_service == Service::IamRoles
2173                {
2174                    crate::iam::actions::prev_filter_focus_roles(self);
2175                } else if self.mode == Mode::FilterInput
2176                    && self.current_service == Service::IamUsers
2177                    && self.iam_state.current_user.is_some()
2178                {
2179                    crate::iam::actions::prev_filter_focus_users(self);
2180                } else if self.mode == Mode::FilterInput
2181                    && self.current_service == Service::IamUserGroups
2182                {
2183                    crate::iam::actions::prev_filter_focus_groups(self);
2184                } else if self.mode == Mode::FilterInput
2185                    && self.current_service == Service::CloudWatchLogGroups
2186                {
2187                    crate::cw::actions::logs_prev_filter_focus(self);
2188                } else if self.mode == Mode::EventFilterInput {
2189                    crate::cw::actions::logs_prev_event_filter_focus(self);
2190                } else if self.mode == Mode::FilterInput
2191                    && self.current_service == Service::IamRoles
2192                    && self.iam_state.current_role.is_some()
2193                {
2194                    crate::iam::actions::prev_filter_focus_roles(self);
2195                } else if self.mode == Mode::FilterInput
2196                    && self.current_service == Service::CloudWatchAlarms
2197                {
2198                    crate::cw::actions::alarms_prev_filter_focus(self);
2199                } else if self.mode == Mode::FilterInput
2200                    && self.current_service == Service::CloudTrailEvents
2201                {
2202                    crate::cloudtrail::actions::prev_filter_focus(self);
2203                } else if self.mode == Mode::FilterInput
2204                    && self.current_service == Service::EcrRepositories
2205                    && self.ecr_state.current_repository.is_none()
2206                {
2207                    crate::ecr::actions::prev_filter_focus(self);
2208                } else if self.mode == Mode::FilterInput && self.current_service == Service::KmsKeys
2209                {
2210                    crate::kms::actions::prev_filter_focus(self);
2211                } else if self.mode == Mode::FilterInput
2212                    && self.current_service == Service::EfsFileSystems
2213                {
2214                    crate::efs::actions::prev_filter_focus(self);
2215                } else if self.mode == Mode::FilterInput
2216                    && self.current_service == Service::DynamoDbTables
2217                {
2218                    ddb_actions::prev_filter_focus(self);
2219                } else if self.mode == Mode::FilterInput
2220                    && self.current_service == Service::FsxFileSystems
2221                {
2222                    crate::fsx::actions::prev_filter_focus(self);
2223                } else if self.mode == Mode::FilterInput
2224                    && self.current_service == Service::LambdaFunctions
2225                {
2226                    crate::lambda::functions::prev_filter_focus(self);
2227                }
2228            }
2229            Action::ToggleFilterCheckbox => {
2230                if self.mode == Mode::FilterInput && self.current_service == Service::Ec2Instances {
2231                    crate::ec2::actions::toggle_filter_checkbox(self);
2232                } else if self.mode == Mode::InsightsInput
2233                    || (self.mode == Mode::Normal
2234                        && self.current_service == Service::CloudWatchInsights)
2235                {
2236                    use crate::app::InsightsFocus;
2237                    if self.insights_state.insights.insights_focus == InsightsFocus::LogGroupSearch
2238                        && self.insights_state.insights.show_dropdown
2239                        && !self.insights_state.insights.log_group_matches.is_empty()
2240                    {
2241                        let selected_idx = self.insights_state.insights.dropdown_selected;
2242                        if let Some(group_name) = self
2243                            .insights_state
2244                            .insights
2245                            .log_group_matches
2246                            .get(selected_idx)
2247                        {
2248                            let group_name = group_name.clone();
2249                            if let Some(pos) = self
2250                                .insights_state
2251                                .insights
2252                                .selected_log_groups
2253                                .iter()
2254                                .position(|g| g == &group_name)
2255                            {
2256                                self.insights_state.insights.selected_log_groups.remove(pos);
2257                            } else if self.insights_state.insights.selected_log_groups.len() < 50 {
2258                                self.insights_state
2259                                    .insights
2260                                    .selected_log_groups
2261                                    .push(group_name);
2262                            }
2263                        }
2264                    }
2265                } else if self.mode == Mode::FilterInput
2266                    && self.current_service == Service::CloudFormationStacks
2267                {
2268                    crate::cfn::actions::toggle_filter_checkbox(self);
2269                } else if self.mode == Mode::FilterInput
2270                    && self.log_groups_state.detail_tab == DetailTab::LogStreams
2271                {
2272                    match self.log_groups_state.input_focus {
2273                        InputFocus::Checkbox("ExactMatch") => {
2274                            self.log_groups_state.exact_match = !self.log_groups_state.exact_match
2275                        }
2276                        InputFocus::Checkbox("ShowExpired") => {
2277                            self.log_groups_state.show_expired = !self.log_groups_state.show_expired
2278                        }
2279                        _ => {}
2280                    }
2281                } else if self.mode == Mode::EventFilterInput
2282                    && self.log_groups_state.event_input_focus == EventFilterFocus::DateRange
2283                {
2284                    self.log_groups_state.relative_unit =
2285                        self.log_groups_state.relative_unit.next();
2286                }
2287            }
2288            Action::CycleSortColumn => {
2289                if self.view_mode == ViewMode::Detail
2290                    && self.log_groups_state.detail_tab == DetailTab::LogStreams
2291                {
2292                    self.log_groups_state.stream_sort = match self.log_groups_state.stream_sort {
2293                        StreamSort::Name => StreamSort::CreationTime,
2294                        StreamSort::CreationTime => StreamSort::LastEventTime,
2295                        StreamSort::LastEventTime => StreamSort::Name,
2296                    };
2297                }
2298            }
2299            Action::ToggleSortDirection => {
2300                if self.view_mode == ViewMode::Detail
2301                    && self.log_groups_state.detail_tab == DetailTab::LogStreams
2302                {
2303                    self.log_groups_state.stream_sort_desc =
2304                        !self.log_groups_state.stream_sort_desc;
2305                }
2306            }
2307            Action::ScrollUp => {
2308                if self.mode == Mode::ErrorModal {
2309                    self.error_scroll = self.error_scroll.saturating_sub(1);
2310                } else if self.current_service == Service::CloudTrailEvents
2311                    && self.cloudtrail_state.current_event.is_some()
2312                {
2313                    self.cloudtrail_state.event_json_scroll =
2314                        self.cloudtrail_state.event_json_scroll.saturating_sub(10);
2315                } else if self.current_service == Service::LambdaFunctions {
2316                    crate::lambda::functions::scroll_up(self);
2317                } else if self.current_service == Service::Ec2Instances {
2318                    crate::ec2::actions::scroll_up(self);
2319                } else if self.current_service == Service::SqsQueues
2320                    && self.sqs_state.current_queue.is_some()
2321                    && self.sqs_state.detail_tab == SqsQueueDetailTab::Monitoring
2322                    && !self.sqs_state.is_metrics_loading()
2323                {
2324                    self.sqs_state.set_monitoring_scroll(
2325                        self.sqs_state.monitoring_scroll().saturating_sub(1),
2326                    );
2327                } else if self.current_service == Service::DynamoDbTables
2328                    && self.dynamodb_state.current_table.is_some()
2329                    && self.dynamodb_state.detail_tab == crate::ui::dynamodb::DetailTab::Monitor
2330                    && !self.dynamodb_state.is_metrics_loading()
2331                {
2332                    ddb_actions::scroll_up_detail(self);
2333                } else if self.current_service == Service::EfsFileSystems
2334                    && self.efs_state.current_file_system.is_some()
2335                    && (self.efs_state.detail_tab == crate::ui::efs::DetailTab::Monitoring
2336                        || self.efs_state.detail_tab == crate::ui::efs::DetailTab::FileSystemPolicy)
2337                    && !self.efs_state.is_metrics_loading()
2338                {
2339                    crate::efs::actions::scroll_up_detail(self);
2340                } else if self.view_mode == ViewMode::PolicyView {
2341                    crate::iam::actions::scroll_up_policy_view(self);
2342                } else if self.current_service == Service::IamRoles
2343                    && self.iam_state.current_role.is_some()
2344                    && self.iam_state.role_tab == RoleTab::TrustRelationships
2345                {
2346                    crate::iam::actions::scroll_up_trust_policy(self);
2347                } else if self.view_mode == ViewMode::Events {
2348                    if self.log_groups_state.event_scroll_offset == 0
2349                        && self.log_groups_state.has_older_events
2350                    {
2351                        self.log_groups_state.loading = true;
2352                    } else {
2353                        self.log_groups_state.event_scroll_offset =
2354                            self.log_groups_state.event_scroll_offset.saturating_sub(1);
2355                    }
2356                } else if self.view_mode == ViewMode::InsightsResults {
2357                    self.insights_state.insights.results_selected = self
2358                        .insights_state
2359                        .insights
2360                        .results_selected
2361                        .saturating_sub(1);
2362                } else if self.view_mode == ViewMode::Detail {
2363                    self.log_groups_state.selected_stream =
2364                        self.log_groups_state.selected_stream.saturating_sub(1);
2365                    self.log_groups_state.expanded_stream = None;
2366                } else if self.view_mode == ViewMode::List
2367                    && self.current_service == Service::CloudWatchLogGroups
2368                {
2369                    self.log_groups_state.log_groups.selected =
2370                        self.log_groups_state.log_groups.selected.saturating_sub(1);
2371                    self.log_groups_state.log_groups.snap_to_page();
2372                } else if self.current_service == Service::EcrRepositories {
2373                    crate::ecr::actions::scroll_up(self);
2374                }
2375            }
2376            Action::ScrollDown => {
2377                if self.mode == Mode::ErrorModal {
2378                    if let Some(error_msg) = &self.error_message {
2379                        let lines = error_msg.lines().count();
2380                        let max_scroll = lines.saturating_sub(1);
2381                        self.error_scroll = (self.error_scroll + 1).min(max_scroll);
2382                    }
2383                } else if self.current_service == Service::CloudTrailEvents
2384                    && self.cloudtrail_state.current_event.is_some()
2385                {
2386                    if let Some(event) = &self.cloudtrail_state.current_event {
2387                        let lines = event.cloud_trail_event_json.lines().count();
2388                        let max_scroll = lines.saturating_sub(1);
2389                        self.cloudtrail_state.event_json_scroll =
2390                            (self.cloudtrail_state.event_json_scroll + 10).min(max_scroll);
2391                    }
2392                } else if self.current_service == Service::SqsQueues
2393                    && self.sqs_state.current_queue.is_some()
2394                    && self.sqs_state.detail_tab == SqsQueueDetailTab::Monitoring
2395                {
2396                    self.sqs_state
2397                        .set_monitoring_scroll((self.sqs_state.monitoring_scroll() + 1).min(1));
2398                } else if self.current_service == Service::DynamoDbTables
2399                    && self.dynamodb_state.current_table.is_some()
2400                    && self.dynamodb_state.detail_tab == crate::ui::dynamodb::DetailTab::Monitor
2401                    && !self.dynamodb_state.is_metrics_loading()
2402                {
2403                    ddb_actions::scroll_down_detail(self);
2404                } else if self.current_service == Service::EfsFileSystems
2405                    && self.efs_state.current_file_system.is_some()
2406                    && (self.efs_state.detail_tab == crate::ui::efs::DetailTab::Monitoring
2407                        || self.efs_state.detail_tab == crate::ui::efs::DetailTab::FileSystemPolicy)
2408                {
2409                    crate::efs::actions::scroll_down_detail(self);
2410                } else if self.view_mode == ViewMode::PolicyView {
2411                    crate::iam::actions::scroll_down_policy_view(self);
2412                } else if self.current_service == Service::IamRoles
2413                    && self.iam_state.current_role.is_some()
2414                    && self.iam_state.role_tab == RoleTab::TrustRelationships
2415                {
2416                    crate::iam::actions::scroll_down_trust_policy(self);
2417                } else if self.view_mode == ViewMode::Events {
2418                    let max_scroll = self.log_groups_state.log_events.len().saturating_sub(1);
2419                    if self.log_groups_state.event_scroll_offset >= max_scroll {
2420                        // At the end, do nothing
2421                    } else {
2422                        self.log_groups_state.event_scroll_offset =
2423                            (self.log_groups_state.event_scroll_offset + 1).min(max_scroll);
2424                    }
2425                } else if self.view_mode == ViewMode::InsightsResults {
2426                    let max = self
2427                        .insights_state
2428                        .insights
2429                        .query_results
2430                        .len()
2431                        .saturating_sub(1);
2432                    self.insights_state.insights.results_selected =
2433                        (self.insights_state.insights.results_selected + 1).min(max);
2434                } else if self.view_mode == ViewMode::Detail {
2435                    let filtered_streams = filtered_log_streams(self);
2436                    let max = filtered_streams.len().saturating_sub(1);
2437                    self.log_groups_state.selected_stream =
2438                        (self.log_groups_state.selected_stream + 1).min(max);
2439                } else if self.view_mode == ViewMode::List
2440                    && self.current_service == Service::CloudWatchLogGroups
2441                {
2442                    let filtered_groups = filtered_log_groups(self);
2443                    self.log_groups_state
2444                        .log_groups
2445                        .next_item(filtered_groups.len());
2446                } else if self.current_service == Service::EcrRepositories {
2447                    crate::ecr::actions::scroll_down(self);
2448                }
2449            }
2450
2451            Action::MeasureLatency => {
2452                if self.mode == Mode::RegionPicker {
2453                    self.measure_region_latencies();
2454                }
2455            }
2456            Action::LoadMore => ddb_items::load_more(self),
2457            Action::Refresh => {
2458                if self.mode == Mode::ProfilePicker {
2459                    self.log_groups_state.loading = true;
2460                } else if self.mode == Mode::RegionPicker {
2461                    // Ctrl+R in region picker just refreshes — latency is via Ctrl+L
2462                } else if self.mode == Mode::SessionPicker {
2463                    self.sessions = Session::list_all().unwrap_or_default();
2464                } else if self.current_service == Service::DynamoDbTables
2465                    && ddb_actions::is_items_tab(self)
2466                {
2467                    ddb_actions::reload_items(self);
2468                } else if self.current_service == Service::CloudWatchAlarms
2469                    && self.alarms_state.current_alarm.is_some()
2470                {
2471                    crate::cw::actions::alarms_refresh(self);
2472                } else if self.current_service == Service::CloudWatchInsights
2473                    && !self.insights_state.insights.selected_log_groups.is_empty()
2474                {
2475                    crate::cw::actions::insights_refresh(self);
2476                } else if self.current_service == Service::LambdaFunctions {
2477                    crate::lambda::functions::refresh(self);
2478                } else if self.current_service == Service::LambdaApplications {
2479                    crate::lambda::applications::refresh(self);
2480                } else if matches!(
2481                    self.view_mode,
2482                    ViewMode::Events | ViewMode::Detail | ViewMode::List
2483                ) {
2484                    self.log_groups_state.loading = true;
2485                }
2486            }
2487            Action::Yank => {
2488                if self.mode == Mode::ErrorModal {
2489                    // Copy error message
2490                    if let Some(error) = &self.error_message {
2491                        self.clipboard.request_text(error);
2492                    }
2493                } else if self.view_mode == ViewMode::Events {
2494                    if let Some(event) = self
2495                        .log_groups_state
2496                        .log_events
2497                        .get(self.log_groups_state.event_scroll_offset)
2498                    {
2499                        self.clipboard.request_text(&event.message);
2500                    }
2501                } else if self.current_service == Service::EcrRepositories {
2502                    crate::ecr::actions::yank(self);
2503                } else if self.current_service == Service::KmsKeys {
2504                    crate::kms::actions::yank(self);
2505                } else if self.current_service == Service::EfsFileSystems {
2506                    crate::efs::actions::yank(self);
2507                } else if self.current_service == Service::DynamoDbTables {
2508                    ddb_actions::yank(self)
2509                } else if self.current_service == Service::FsxFileSystems {
2510                    crate::fsx::actions::yank(self);
2511                } else if self.current_service == Service::LambdaFunctions {
2512                    crate::lambda::functions::yank(self);
2513                } else if self.current_service == Service::CloudFormationStacks {
2514                    crate::cfn::actions::yank(self);
2515                } else if self.current_service == Service::CloudTrailEvents
2516                    && self.cloudtrail_state.current_event.is_some()
2517                {
2518                    crate::cloudtrail::actions::yank(self);
2519                } else if self.current_service == Service::IamUsers {
2520                    crate::iam::actions::yank_users(self);
2521                } else if self.current_service == Service::IamRoles {
2522                    crate::iam::actions::yank_roles(self);
2523                } else if self.current_service == Service::IamUserGroups {
2524                    crate::iam::actions::yank_groups(self);
2525                } else if self.current_service == Service::SqsQueues {
2526                    if self.sqs_state.current_queue.is_some() {
2527                        // In queue detail view - copy queue ARN
2528                        if let Some(queue) = self
2529                            .sqs_state
2530                            .queues
2531                            .items
2532                            .iter()
2533                            .find(|q| Some(&q.url) == self.sqs_state.current_queue.as_ref())
2534                        {
2535                            let arn = format!(
2536                                "arn:aws:sqs:{}:{}:{}",
2537                                extract_region(&queue.url),
2538                                extract_account_id(&queue.url),
2539                                queue.name
2540                            );
2541                            self.clipboard.request_text(&arn);
2542                        }
2543                    } else {
2544                        // In list view - copy selected queue ARN
2545                        let filtered_queues = filtered_queues(
2546                            &self.sqs_state.queues.items,
2547                            &self.sqs_state.queues.filter,
2548                        );
2549                        if let Some(queue) = self.sqs_state.queues.get_selected(&filtered_queues) {
2550                            let arn = format!(
2551                                "arn:aws:sqs:{}:{}:{}",
2552                                extract_region(&queue.url),
2553                                extract_account_id(&queue.url),
2554                                queue.name
2555                            );
2556                            self.clipboard.request_text(&arn);
2557                        }
2558                    }
2559                } else if self.current_service == Service::ApiGatewayApis {
2560                    crate::apig::actions::yank(self);
2561                }
2562            }
2563            Action::CopyToClipboard => {
2564                // Request snapshot - will be captured after next render
2565                self.clipboard.request_snapshot();
2566            }
2567            Action::RetryLoad => {
2568                self.error_message = None;
2569                self.mode = Mode::Normal;
2570                if self.current_service == Service::DynamoDbTables
2571                    && ddb_actions::is_items_tab(self)
2572                    && self.dynamodb_state.items_failed
2573                {
2574                    ddb_items::retry(self);
2575                } else {
2576                    self.log_groups_state.loading = true;
2577                }
2578            }
2579            Action::ApplyFilter => {
2580                if self.mode == Mode::FilterInput
2581                    && self.current_service == Service::SqsQueues
2582                    && self.sqs_state.input_focus == InputFocus::Dropdown("SubscriptionRegion")
2583                {
2584                    let regions = AwsRegion::all();
2585                    if let Some(region) = regions.get(self.sqs_state.subscription_region_selected) {
2586                        self.sqs_state.subscription_region_filter = region.code.to_string();
2587                    }
2588                    self.mode = Mode::Normal;
2589                } else if self.mode == Mode::FilterInput
2590                    && self.current_service == Service::CloudTrailEvents
2591                {
2592                    // Apply filter: set API-level EventName filter and reload
2593                    let filter_text = self.cloudtrail_state.table.filter.trim().to_string();
2594                    self.cloudtrail_state.active_event_name_filter = if filter_text.is_empty() {
2595                        None
2596                    } else {
2597                        Some(filter_text)
2598                    };
2599                    // Clear existing items and trigger reload from API
2600                    self.cloudtrail_state.table.items.clear();
2601                    self.cloudtrail_state.table.next_token = None;
2602                    self.cloudtrail_state.table.selected = 0;
2603                    self.cloudtrail_state.table.scroll_offset = 0;
2604                    self.cloudtrail_state.table.loading = true;
2605                    self.mode = Mode::Normal;
2606                } else if self.mode == Mode::InsightsInput {
2607                    use crate::app::InsightsFocus;
2608                    if self.insights_state.insights.insights_focus == InsightsFocus::LogGroupSearch
2609                        && self.insights_state.insights.show_dropdown
2610                    {
2611                        // First toggle the currently highlighted entry (select it)
2612                        let selected_idx = self.insights_state.insights.dropdown_selected;
2613                        if let Some(group_name) = self
2614                            .insights_state
2615                            .insights
2616                            .log_group_matches
2617                            .get(selected_idx)
2618                            .cloned()
2619                        {
2620                            if let Some(pos) = self
2621                                .insights_state
2622                                .insights
2623                                .selected_log_groups
2624                                .iter()
2625                                .position(|g| g == &group_name)
2626                            {
2627                                self.insights_state.insights.selected_log_groups.remove(pos);
2628                            } else if self.insights_state.insights.selected_log_groups.len() < 50 {
2629                                self.insights_state
2630                                    .insights
2631                                    .selected_log_groups
2632                                    .push(group_name);
2633                            }
2634                        }
2635                        // Close dropdown and clear search
2636                        self.insights_state.insights.show_dropdown = false;
2637                        self.insights_state.insights.log_group_search.clear();
2638                        self.insights_state.insights.log_group_matches.clear();
2639                        self.mode = Mode::Normal;
2640                    }
2641                } else if self.mode == Mode::Normal && !self.page_input.is_empty() {
2642                    if let Ok(page) = self.page_input.parse::<usize>() {
2643                        self.go_to_page(page);
2644                    }
2645                    self.page_input.clear();
2646                } else {
2647                    self.mode = Mode::Normal;
2648                    self.log_groups_state.filter_mode = false;
2649                }
2650            }
2651            Action::ToggleExactMatch => {
2652                if self.view_mode == ViewMode::Detail
2653                    && self.log_groups_state.detail_tab == DetailTab::LogStreams
2654                {
2655                    self.log_groups_state.exact_match = !self.log_groups_state.exact_match;
2656                }
2657            }
2658            Action::ToggleShowExpired => {
2659                if self.view_mode == ViewMode::Detail
2660                    && self.log_groups_state.detail_tab == DetailTab::LogStreams
2661                {
2662                    self.log_groups_state.show_expired = !self.log_groups_state.show_expired;
2663                }
2664            }
2665            Action::GoBack => {
2666                // ServicePicker: close if we have tabs
2667                if self.mode == Mode::ServicePicker && !self.tabs.is_empty() {
2668                    self.mode = Mode::Normal;
2669                    self.service_picker.filter.clear();
2670                }
2671                // API Gateway: go back from API detail to list
2672                else if self.current_service == Service::ApiGatewayApis
2673                    && self.apig_state.current_api.is_some()
2674                {
2675                    crate::apig::actions::go_back(self);
2676                }
2677                // S3: pop navigation stack first, then exit bucket
2678                else if self.current_service == Service::S3Buckets
2679                    && self.s3_state.current_bucket.is_some()
2680                {
2681                    crate::s3::actions::go_back(self);
2682                }
2683                // ECR: go back from images to repositories
2684                else if self.current_service == Service::EcrRepositories
2685                    && self.ecr_state.current_repository.is_some()
2686                {
2687                    crate::ecr::actions::go_back(self);
2688                } else if self.current_service == Service::EfsFileSystems
2689                    && self.efs_state.current_file_system.is_some()
2690                {
2691                    crate::efs::actions::go_back(self);
2692                } else if self.current_service == Service::DynamoDbTables
2693                    && self.dynamodb_state.current_table.is_some()
2694                {
2695                    ddb_actions::go_back(self);
2696                } else if self.current_service == Service::FsxFileSystems
2697                    && self.fsx_state.current_file_system.is_some()
2698                {
2699                    crate::fsx::actions::go_back(self);
2700                }
2701                // EC2: go back from instance detail to list
2702                else if self.current_service == Service::Ec2Instances
2703                    && self.ec2_state.current_instance.is_some()
2704                {
2705                    crate::ec2::actions::go_back(self);
2706                }
2707                // SQS: go back from queue detail to list
2708                else if self.current_service == Service::SqsQueues
2709                    && self.sqs_state.current_queue.is_some()
2710                {
2711                    self.sqs_state.current_queue = None;
2712                }
2713                // IAM: go back
2714                else if self.current_service == Service::IamUsers
2715                    && self.iam_state.current_user.is_some()
2716                {
2717                    crate::iam::actions::go_back_users(self);
2718                } else if self.current_service == Service::IamUserGroups
2719                    && self.iam_state.current_group.is_some()
2720                {
2721                    crate::iam::actions::go_back_groups(self);
2722                } else if self.current_service == Service::IamRoles {
2723                    crate::iam::actions::go_back_roles(self);
2724                }
2725                // Lambda: go back from version/alias/function detail
2726                else if self.current_service == Service::LambdaFunctions
2727                    && (self.lambda_state.current_version.is_some()
2728                        || self.lambda_state.current_alias.is_some()
2729                        || self.lambda_state.current_function.is_some())
2730                {
2731                    crate::lambda::functions::go_back(self);
2732                }
2733                // CloudWatch Alarms: go back from alarm detail to list
2734                else if self.current_service == Service::CloudWatchAlarms
2735                    && self.alarms_state.current_alarm.is_some()
2736                {
2737                    crate::cw::actions::alarms_go_back(self);
2738                }
2739                // Lambda Applications: go back from application detail to list
2740                else if self.current_service == Service::LambdaApplications
2741                    && self.lambda_application_state.current_application.is_some()
2742                {
2743                    crate::lambda::applications::go_back(self);
2744                }
2745                // CloudFormation: go back from stack detail to list
2746                else if self.current_service == Service::CloudFormationStacks
2747                    && self.cfn_state.current_stack.is_some()
2748                {
2749                    crate::cfn::actions::go_back(self);
2750                }
2751                // CloudTrail: go back from event detail to list
2752                else if self.current_service == Service::CloudTrailEvents
2753                    && self.cloudtrail_state.current_event.is_some()
2754                {
2755                    crate::cloudtrail::actions::go_back(self);
2756                }
2757                // From insights results -> collapse if expanded, otherwise back to sidebar
2758                else if self.view_mode == ViewMode::InsightsResults {
2759                    if self.insights_state.insights.expanded_result.is_some() {
2760                        self.insights_state.insights.expanded_result = None;
2761                    }
2762                }
2763                // From alarms view -> collapse if expanded
2764                else if self.current_service == Service::CloudWatchAlarms {
2765                    crate::cw::actions::alarms_go_back_list(self);
2766                }
2767                // From EC2 instances view -> always collapse
2768                else if self.current_service == Service::Ec2Instances {
2769                    crate::ec2::actions::collapse_row(self);
2770                }
2771                // From events view -> collapse if expanded, otherwise back to detail view
2772                else if self.view_mode == ViewMode::Events {
2773                    if self.log_groups_state.expanded_event.is_some() {
2774                        self.log_groups_state.expanded_event = None;
2775                    } else {
2776                        self.view_mode = ViewMode::Detail;
2777                        self.log_groups_state.event_filter.clear();
2778                    }
2779                }
2780                // From detail view -> back to list view
2781                else if self.view_mode == ViewMode::Detail {
2782                    self.view_mode = ViewMode::List;
2783                    self.log_groups_state.stream_filter.clear();
2784                    self.log_groups_state.exact_match = false;
2785                    self.log_groups_state.show_expired = false;
2786                }
2787            }
2788            Action::OpenInConsole | Action::OpenInBrowser => {
2789                let url = self.get_console_url();
2790                let _ = webbrowser::open(&url);
2791            }
2792            Action::ShowHelp => {
2793                self.mode = Mode::HelpModal;
2794            }
2795            Action::OpenRegionPicker => {
2796                self.region_filter.clear();
2797                self.region_picker_selected = 0;
2798                self.mode = Mode::RegionPicker;
2799            }
2800            Action::OpenProfilePicker => {
2801                self.profile_filter.clear();
2802                self.profile_picker_selected = 0;
2803                self.available_profiles = Self::load_aws_profiles();
2804                self.mode = Mode::ProfilePicker;
2805            }
2806            Action::OpenCalendar => {
2807                self.calendar_date = Some(time::OffsetDateTime::now_utc().date());
2808                self.calendar_selecting = CalendarField::StartDate;
2809                self.mode = Mode::CalendarPicker;
2810            }
2811            Action::CloseCalendar => {
2812                self.mode = Mode::Normal;
2813                self.calendar_date = None;
2814            }
2815            Action::CalendarPrevDay => {
2816                if let Some(date) = self.calendar_date {
2817                    self.calendar_date = date.checked_sub(time::Duration::days(1));
2818                }
2819            }
2820            Action::CalendarNextDay => {
2821                if let Some(date) = self.calendar_date {
2822                    self.calendar_date = date.checked_add(time::Duration::days(1));
2823                }
2824            }
2825            Action::CalendarPrevWeek => {
2826                if let Some(date) = self.calendar_date {
2827                    self.calendar_date = date.checked_sub(time::Duration::weeks(1));
2828                }
2829            }
2830            Action::CalendarNextWeek => {
2831                if let Some(date) = self.calendar_date {
2832                    self.calendar_date = date.checked_add(time::Duration::weeks(1));
2833                }
2834            }
2835            Action::CalendarPrevMonth => {
2836                if let Some(date) = self.calendar_date {
2837                    self.calendar_date = Some(if date.month() == time::Month::January {
2838                        date.replace_month(time::Month::December)
2839                            .unwrap()
2840                            .replace_year(date.year() - 1)
2841                            .unwrap()
2842                    } else {
2843                        date.replace_month(date.month().previous()).unwrap()
2844                    });
2845                }
2846            }
2847            Action::CalendarNextMonth => {
2848                if let Some(date) = self.calendar_date {
2849                    self.calendar_date = Some(if date.month() == time::Month::December {
2850                        date.replace_month(time::Month::January)
2851                            .unwrap()
2852                            .replace_year(date.year() + 1)
2853                            .unwrap()
2854                    } else {
2855                        date.replace_month(date.month().next()).unwrap()
2856                    });
2857                }
2858            }
2859            Action::CalendarSelect => {
2860                if let Some(date) = self.calendar_date {
2861                    let timestamp = time::OffsetDateTime::new_utc(date, time::Time::MIDNIGHT)
2862                        .unix_timestamp()
2863                        * 1000;
2864                    match self.calendar_selecting {
2865                        CalendarField::StartDate => {
2866                            self.log_groups_state.start_time = Some(timestamp);
2867                            self.calendar_selecting = CalendarField::EndDate;
2868                        }
2869                        CalendarField::EndDate => {
2870                            self.log_groups_state.end_time = Some(timestamp);
2871                            self.mode = Mode::Normal;
2872                            self.calendar_date = None;
2873                        }
2874                    }
2875                }
2876            }
2877        }
2878    }
2879
2880    pub fn filtered_services(&self) -> Vec<&'static str> {
2881        let mut services = if self.service_picker.filter.is_empty() {
2882            self.service_picker.services.clone()
2883        } else {
2884            self.service_picker
2885                .services
2886                .iter()
2887                .filter(|s| {
2888                    s.to_lowercase()
2889                        .contains(&self.service_picker.filter.to_lowercase())
2890                })
2891                .copied()
2892                .collect()
2893        };
2894        services.sort();
2895        services
2896    }
2897
2898    pub fn breadcrumbs(&self) -> String {
2899        if !self.service_selected {
2900            return String::new();
2901        }
2902
2903        let mut parts = vec![];
2904
2905        match self.current_service {
2906            Service::CloudWatchLogGroups => {
2907                parts.push("CloudWatch".to_string());
2908                parts.push("Log groups".to_string());
2909
2910                if self.view_mode != ViewMode::List {
2911                    if let Some(group) = selected_log_group(self) {
2912                        parts.push(group.name.clone());
2913                    }
2914                }
2915
2916                if self.view_mode == ViewMode::Events {
2917                    if let Some(stream) = self
2918                        .log_groups_state
2919                        .log_streams
2920                        .get(self.log_groups_state.selected_stream)
2921                    {
2922                        parts.push(stream.name.clone());
2923                    }
2924                }
2925            }
2926            Service::CloudWatchInsights => {
2927                parts.push("CloudWatch".to_string());
2928                parts.push("Logs Insights".to_string());
2929            }
2930            Service::CloudWatchAlarms => {
2931                parts.extend(crate::cw::actions::alarms_breadcrumb());
2932            }
2933            Service::CloudTrailEvents => {
2934                parts.extend(crate::cloudtrail::actions::breadcrumb(self));
2935            }
2936            Service::S3Buckets => {
2937                parts.extend(crate::s3::actions::breadcrumb(self));
2938            }
2939            Service::SqsQueues => {
2940                parts.extend(crate::sqs::actions::breadcrumb());
2941            }
2942            Service::EcrRepositories => {
2943                parts.extend(crate::ecr::actions::breadcrumb(self));
2944            }
2945            Service::KmsKeys => {
2946                parts.extend(crate::kms::actions::breadcrumb(self));
2947            }
2948            Service::EfsFileSystems => {
2949                parts.extend(crate::efs::actions::breadcrumb(self));
2950            }
2951            Service::RdsInstances => parts.extend(["RDS".into(), "Databases".into()]),
2952            Service::VpcVpcs => parts.extend(["VPC".into(), "VPCs".into()]),
2953            Service::FsxFileSystems => {
2954                parts.extend(crate::fsx::actions::breadcrumb(self));
2955            }
2956            Service::DynamoDbTables => {
2957                parts.extend(ddb_actions::breadcrumb(self));
2958            }
2959            Service::LambdaFunctions => {
2960                parts.extend(crate::lambda::functions::breadcrumb(self));
2961            }
2962            Service::LambdaApplications => {
2963                parts.extend(crate::lambda::applications::breadcrumb());
2964            }
2965            Service::CloudFormationStacks => {
2966                parts.extend(crate::cfn::actions::breadcrumb(self));
2967            }
2968            Service::IamUsers => {
2969                parts.extend(crate::iam::actions::breadcrumb_users());
2970            }
2971            Service::IamRoles => {
2972                parts.extend(crate::iam::actions::breadcrumb_roles(self));
2973            }
2974            Service::IamUserGroups => {
2975                parts.extend(crate::iam::actions::breadcrumb_groups(self));
2976            }
2977            Service::Ec2Instances => {
2978                parts.extend(crate::ec2::actions::breadcrumb(self));
2979            }
2980            Service::ApiGatewayApis => {
2981                parts.extend(crate::apig::actions::breadcrumb(self));
2982            }
2983        }
2984
2985        parts.join(" > ")
2986    }
2987
2988    pub fn update_current_tab_breadcrumb(&mut self) {
2989        if !self.tabs.is_empty() {
2990            self.tabs[self.current_tab].breadcrumb = self.breadcrumbs();
2991        }
2992    }
2993
2994    pub fn get_console_url(&self) -> String {
2995        use crate::cw;
2996
2997        match self.current_service {
2998            Service::CloudWatchLogGroups => {
2999                if self.view_mode == ViewMode::Events {
3000                    if let Some(group) = selected_log_group(self) {
3001                        if let Some(stream) = self
3002                            .log_groups_state
3003                            .log_streams
3004                            .get(self.log_groups_state.selected_stream)
3005                        {
3006                            return cw::logs::console_url_stream(
3007                                &self.config.region,
3008                                &group.name,
3009                                &stream.name,
3010                            );
3011                        }
3012                    }
3013                } else if self.view_mode == ViewMode::Detail {
3014                    if let Some(group) = selected_log_group(self) {
3015                        return cw::logs::console_url_detail(&self.config.region, &group.name);
3016                    }
3017                }
3018                cw::logs::console_url_list(&self.config.region)
3019            }
3020            Service::CloudWatchInsights => cw::insights::console_url(
3021                &self.config.region,
3022                &self.config.account_id,
3023                &self.insights_state.insights.query_text,
3024                &self.insights_state.insights.selected_log_groups,
3025            ),
3026            Service::CloudWatchAlarms => {
3027                // If drilled into alarm detail use that name; otherwise use selected row
3028                let alarm_name = if let Some(name) = self.alarms_state.current_alarm.as_deref() {
3029                    Some(name.to_string())
3030                } else {
3031                    // In list view — get the currently selected alarm name
3032                    let page_size = self.alarms_state.table.page_size.value();
3033                    let current_page = self.alarms_state.table.selected / page_size;
3034                    let start = current_page * page_size;
3035                    let filtered: Vec<_> = self.alarms_state.table.items.iter().collect();
3036                    let relative = self.alarms_state.table.selected.saturating_sub(start);
3037                    filtered.get(start + relative).map(|a| a.name.clone())
3038                };
3039                cw::alarms::console_url(&self.config.region, alarm_name.as_deref())
3040            }
3041            Service::CloudTrailEvents => crate::cloudtrail::actions::console_url(self),
3042            Service::S3Buckets => crate::s3::actions::console_url(self),
3043            Service::SqsQueues => crate::sqs::actions::console_url(self),
3044            Service::EcrRepositories => crate::ecr::actions::console_url(self),
3045            Service::KmsKeys => crate::kms::actions::console_url(self),
3046            Service::EfsFileSystems => crate::efs::actions::console_url(self),
3047            Service::FsxFileSystems => crate::fsx::actions::console_url(self),
3048            Service::RdsInstances => rds_actions::console_url(self),
3049            Service::VpcVpcs => vpc_actions::console_url(self),
3050            Service::DynamoDbTables => ddb_actions::console_url(self),
3051            Service::LambdaFunctions => crate::lambda::functions::console_url(self),
3052            Service::LambdaApplications => crate::lambda::applications::console_url(self),
3053            Service::CloudFormationStacks => crate::cfn::actions::console_url(self),
3054            Service::IamUsers => crate::iam::actions::console_url_users(self),
3055            Service::IamRoles => crate::iam::actions::console_url_roles(self),
3056            Service::IamUserGroups => crate::iam::actions::console_url_groups(self),
3057            Service::Ec2Instances => crate::ec2::actions::console_url(self),
3058            Service::ApiGatewayApis => crate::apig::actions::console_url(self),
3059        }
3060    }
3061
3062    pub fn calculate_total_bucket_rows(&self) -> usize {
3063        calculate_total_bucket_rows(self)
3064    }
3065
3066    pub fn calculate_total_object_rows(&self) -> usize {
3067        calculate_total_object_rows(self)
3068    }
3069
3070    fn get_column_selector_max(&self) -> usize {
3071        crate::preferences::bindings::model_for(self, self.current_service).max_index()
3072    }
3073
3074    #[cfg(test)]
3075    fn get_column_count(&self) -> usize {
3076        crate::preferences::bindings::model_for(self, self.current_service).column_count()
3077    }
3078
3079    fn is_blank_row_index(&self, idx: usize) -> bool {
3080        crate::preferences::bindings::model_for(self, self.current_service).is_separator(idx)
3081    }
3082
3083    fn next_item(&mut self) {
3084        match self.mode {
3085            Mode::FilterInput => {
3086                if self.current_service == Service::S3Buckets
3087                    && crate::s3::actions::is_pagination_focused(self)
3088                {
3089                    crate::s3::actions::page_down_filter_input(self);
3090                } else if self.current_service == Service::CloudTrailEvents
3091                    && self.cloudtrail_state.input_focus == InputFocus::Pagination
3092                {
3093                    // Navigate to next page
3094                    let page_size = self.cloudtrail_state.table.page_size.value();
3095                    let total_items = self.cloudtrail_state.table.items.len();
3096                    let current_page = self.cloudtrail_state.table.selected / page_size;
3097                    let total_pages = total_items.div_ceil(page_size);
3098                    if current_page + 1 < total_pages {
3099                        self.cloudtrail_state.table.selected = (current_page + 1) * page_size;
3100                    }
3101                } else if self.current_service == Service::CloudFormationStacks {
3102                    crate::cfn::actions::next_item_filter_input(self);
3103                } else if self.current_service == Service::IamUsers
3104                    && self.iam_state.current_user.is_some()
3105                {
3106                    crate::iam::actions::next_item_filter_input_users(self);
3107                } else if self.current_service == Service::IamRoles
3108                    && self.iam_state.current_role.is_some()
3109                    && self.iam_state.role_tab == RoleTab::Permissions
3110                {
3111                    crate::iam::actions::next_item_filter_input_roles(self);
3112                } else if self.current_service == Service::DynamoDbTables {
3113                    ddb_actions::next_dropdown_value(self);
3114                } else if self.current_service == Service::Ec2Instances {
3115                    crate::ec2::actions::toggle_state_filter_next(self);
3116                } else if self.current_service == Service::SqsQueues {
3117                    crate::sqs::actions::next_item_filter_input(self);
3118                }
3119            }
3120            Mode::RegionPicker => {
3121                let filtered = self.get_filtered_regions();
3122                if !filtered.is_empty() {
3123                    self.region_picker_selected =
3124                        (self.region_picker_selected + 1).min(filtered.len() - 1);
3125                }
3126            }
3127            Mode::ProfilePicker => {
3128                let filtered = self.get_filtered_profiles();
3129                if !filtered.is_empty() {
3130                    self.profile_picker_selected =
3131                        (self.profile_picker_selected + 1).min(filtered.len() - 1);
3132                }
3133            }
3134            Mode::SessionPicker => {
3135                let filtered = self.get_filtered_sessions();
3136                if !filtered.is_empty() {
3137                    self.session_picker_selected =
3138                        (self.session_picker_selected + 1).min(filtered.len() - 1);
3139                }
3140            }
3141            Mode::InsightsInput => {
3142                crate::cw::actions::insights_next_item_dropdown(self);
3143            }
3144            Mode::ColumnSelector => {
3145                self.column_selector_index =
3146                    crate::preferences::bindings::model_for(self, self.current_service)
3147                        .move_row(self.column_selector_index, true);
3148            }
3149            Mode::ServicePicker => {
3150                let filtered = self.filtered_services();
3151                if !filtered.is_empty() {
3152                    self.service_picker.selected =
3153                        (self.service_picker.selected + 1).min(filtered.len() - 1);
3154                }
3155            }
3156            Mode::TabPicker => {
3157                let filtered = self.get_filtered_tabs();
3158                if !filtered.is_empty() {
3159                    self.tab_picker_selected =
3160                        (self.tab_picker_selected + 1).min(filtered.len() - 1);
3161                }
3162            }
3163            Mode::Normal => {
3164                if !self.service_selected {
3165                    let filtered = self.filtered_services();
3166                    if !filtered.is_empty() {
3167                        self.service_picker.selected =
3168                            (self.service_picker.selected + 1).min(filtered.len() - 1);
3169                    }
3170                } else if self.current_service == Service::S3Buckets {
3171                    crate::s3::actions::next_item(self);
3172                } else if self.view_mode == ViewMode::InsightsResults {
3173                    crate::cw::actions::insights_next_item_results(self);
3174                } else if self.current_service == Service::CloudWatchInsights
3175                    && self.insights_state.insights.show_dropdown
3176                {
3177                    // Navigate dropdown in Normal mode
3178                    crate::cw::actions::insights_next_item_dropdown(self);
3179                } else if self.current_service == Service::ApiGatewayApis {
3180                    crate::apig::actions::next_item(self);
3181                } else if self.current_service == Service::SqsQueues
3182                    && self.sqs_state.current_queue.is_some()
3183                    && (self.sqs_state.detail_tab == SqsQueueDetailTab::QueuePolicies
3184                        || (self.sqs_state.detail_tab == SqsQueueDetailTab::Monitoring
3185                            && !self.sqs_state.is_metrics_loading()))
3186                {
3187                    crate::sqs::actions::scroll_down_detail(self);
3188                } else if self.view_mode == ViewMode::Events {
3189                    let max_scroll = self.log_groups_state.log_events.len().saturating_sub(1);
3190                    if self.log_groups_state.event_scroll_offset < max_scroll {
3191                        self.log_groups_state.event_scroll_offset =
3192                            (self.log_groups_state.event_scroll_offset + 1).min(max_scroll);
3193                    }
3194                } else if self.current_service == Service::CloudWatchLogGroups {
3195                    crate::cw::actions::logs_next_item(self);
3196                } else if self.current_service == Service::CloudWatchAlarms {
3197                    crate::cw::actions::alarms_next_item(self);
3198                } else if self.current_service == Service::CloudTrailEvents {
3199                    crate::cloudtrail::actions::next_item(self);
3200                } else if self.current_service == Service::Ec2Instances {
3201                    crate::ec2::actions::next_item(self);
3202                } else if self.current_service == Service::EcrRepositories {
3203                    crate::ecr::actions::next_item(self);
3204                } else if self.current_service == Service::KmsKeys {
3205                    crate::kms::actions::next_item(self);
3206                } else if self.current_service == Service::EfsFileSystems {
3207                    crate::efs::actions::next_item(self);
3208                } else if self.current_service == Service::DynamoDbTables {
3209                    ddb_actions::next_item(self)
3210                } else if self.current_service == Service::FsxFileSystems {
3211                    crate::fsx::actions::next_item(self);
3212                } else if self.current_service == Service::SqsQueues {
3213                    crate::sqs::actions::next_item(self);
3214                } else if self.current_service == Service::LambdaFunctions {
3215                    crate::lambda::functions::next_item(self);
3216                } else if self.current_service == Service::LambdaApplications {
3217                    crate::lambda::applications::next_item(self);
3218                } else if self.current_service == Service::CloudFormationStacks {
3219                    crate::cfn::actions::next_item(self);
3220                } else if self.current_service == Service::IamUsers {
3221                    crate::iam::actions::next_item_users(self);
3222                } else if self.current_service == Service::IamRoles {
3223                    crate::iam::actions::next_item_roles(self);
3224                } else if self.current_service == Service::IamUserGroups {
3225                    crate::iam::actions::next_item_groups(self);
3226                }
3227            }
3228            _ => {}
3229        }
3230    }
3231
3232    fn prev_item(&mut self) {
3233        match self.mode {
3234            Mode::FilterInput => {
3235                if self.current_service == Service::S3Buckets
3236                    && crate::s3::actions::is_pagination_focused(self)
3237                {
3238                    crate::s3::actions::page_up_filter_input(self);
3239                } else if self.current_service == Service::CloudTrailEvents
3240                    && self.cloudtrail_state.input_focus == InputFocus::Pagination
3241                {
3242                    // Navigate to previous page
3243                    let page_size = self.cloudtrail_state.table.page_size.value();
3244                    let current_page = self.cloudtrail_state.table.selected / page_size;
3245                    if current_page > 0 {
3246                        self.cloudtrail_state.table.selected = (current_page - 1) * page_size;
3247                    }
3248                } else if self.current_service == Service::CloudFormationStacks {
3249                    crate::cfn::actions::prev_item_filter_input(self);
3250                } else if self.current_service == Service::IamUsers
3251                    && self.iam_state.current_user.is_some()
3252                {
3253                    crate::iam::actions::prev_item_filter_input_users(self);
3254                } else if self.current_service == Service::IamRoles
3255                    && self.iam_state.current_role.is_some()
3256                    && self.iam_state.role_tab == RoleTab::Permissions
3257                {
3258                    crate::iam::actions::prev_item_filter_input_roles(self);
3259                } else if self.current_service == Service::DynamoDbTables {
3260                    ddb_actions::prev_dropdown_value(self);
3261                } else if self.current_service == Service::Ec2Instances {
3262                    crate::ec2::actions::toggle_state_filter_prev(self);
3263                } else if self.current_service == Service::SqsQueues {
3264                    use crate::ui::sqs::SUBSCRIPTION_REGION;
3265                    if self.sqs_state.input_focus == SUBSCRIPTION_REGION {
3266                        self.sqs_state.subscription_region_selected = self
3267                            .sqs_state
3268                            .subscription_region_selected
3269                            .saturating_sub(1);
3270                        self.sqs_state.subscriptions.reset();
3271                    }
3272                }
3273            }
3274            Mode::RegionPicker => {
3275                self.region_picker_selected = self.region_picker_selected.saturating_sub(1);
3276            }
3277            Mode::ProfilePicker => {
3278                self.profile_picker_selected = self.profile_picker_selected.saturating_sub(1);
3279            }
3280            Mode::SessionPicker => {
3281                self.session_picker_selected = self.session_picker_selected.saturating_sub(1);
3282            }
3283            Mode::InsightsInput => {
3284                crate::cw::actions::insights_prev_item_dropdown(self);
3285            }
3286            Mode::ColumnSelector => {
3287                self.column_selector_index =
3288                    crate::preferences::bindings::model_for(self, self.current_service)
3289                        .move_row(self.column_selector_index, false);
3290            }
3291            Mode::ServicePicker => {
3292                self.service_picker.selected = self.service_picker.selected.saturating_sub(1);
3293            }
3294            Mode::TabPicker => {
3295                self.tab_picker_selected = self.tab_picker_selected.saturating_sub(1);
3296            }
3297            Mode::Normal => {
3298                if !self.service_selected {
3299                    self.service_picker.selected = self.service_picker.selected.saturating_sub(1);
3300                } else if self.current_service == Service::S3Buckets {
3301                    crate::s3::actions::prev_item(self);
3302                } else if self.current_service == Service::ApiGatewayApis {
3303                    crate::apig::actions::prev_item(self);
3304                } else if self.view_mode == ViewMode::InsightsResults {
3305                    if self.insights_state.insights.results_selected > 0 {
3306                        self.insights_state.insights.results_selected -= 1;
3307                    }
3308                } else if self.current_service == Service::CloudWatchInsights
3309                    && self.insights_state.insights.show_dropdown
3310                {
3311                    crate::cw::actions::insights_prev_item_dropdown(self);
3312                } else if self.view_mode == ViewMode::PolicyView {
3313                    crate::iam::actions::scroll_up_policy_view_one(self);
3314                } else if self.current_service == Service::SqsQueues
3315                    && self.sqs_state.current_queue.is_some()
3316                    && self.sqs_state.detail_tab == SqsQueueDetailTab::QueuePolicies
3317                {
3318                } else if self.current_service == Service::LambdaFunctions
3319                    && self.lambda_state.current_function.is_some()
3320                    && self.lambda_state.detail_tab == LambdaDetailTab::Monitor
3321                    && !self.lambda_state.is_metrics_loading()
3322                {
3323                    crate::lambda::functions::scroll_up(self);
3324                } else if self.current_service == Service::SqsQueues
3325                    && self.sqs_state.current_queue.is_some()
3326                    && self.sqs_state.detail_tab == SqsQueueDetailTab::Monitoring
3327                    && !self.sqs_state.is_metrics_loading()
3328                {
3329                    self.sqs_state.set_monitoring_scroll(
3330                        self.sqs_state.monitoring_scroll().saturating_sub(1),
3331                    );
3332                } else if self.view_mode == ViewMode::Events {
3333                    if self.log_groups_state.event_scroll_offset == 0 {
3334                        if self.log_groups_state.has_older_events {
3335                            self.log_groups_state.loading = true;
3336                        }
3337                        // Don't move if at position 0
3338                    } else {
3339                        self.log_groups_state.event_scroll_offset =
3340                            self.log_groups_state.event_scroll_offset.saturating_sub(1);
3341                    }
3342                } else if self.current_service == Service::CloudWatchLogGroups {
3343                    crate::cw::actions::logs_prev_item(self);
3344                } else if self.current_service == Service::CloudWatchAlarms {
3345                    crate::cw::actions::alarms_prev_item(self);
3346                } else if self.current_service == Service::CloudTrailEvents {
3347                    crate::cloudtrail::actions::prev_item(self);
3348                } else if self.current_service == Service::Ec2Instances {
3349                    if self.ec2_state.current_instance.is_some()
3350                        && self.ec2_state.detail_tab == Ec2DetailTab::Tags
3351                    {
3352                        self.ec2_state.tags.prev_item();
3353                    } else {
3354                        self.ec2_state.table.prev_item();
3355                    }
3356                } else if self.current_service == Service::EcrRepositories {
3357                    crate::ecr::actions::prev_item(self);
3358                } else if self.current_service == Service::KmsKeys {
3359                    crate::kms::actions::prev_item(self);
3360                } else if self.current_service == Service::EfsFileSystems {
3361                    crate::efs::actions::prev_item(self);
3362                } else if self.current_service == Service::DynamoDbTables {
3363                    ddb_actions::prev_item(self)
3364                } else if self.current_service == Service::FsxFileSystems {
3365                    crate::fsx::actions::prev_item(self);
3366                } else if self.current_service == Service::SqsQueues {
3367                    crate::sqs::actions::prev_item(self);
3368                } else if self.current_service == Service::LambdaFunctions {
3369                    crate::lambda::functions::prev_item(self);
3370                } else if self.current_service == Service::LambdaApplications {
3371                    crate::lambda::applications::prev_item(self);
3372                } else if self.current_service == Service::CloudFormationStacks {
3373                    crate::cfn::actions::prev_item(self);
3374                } else if self.current_service == Service::IamUsers {
3375                    crate::iam::actions::prev_item_users(self);
3376                } else if self.current_service == Service::IamRoles {
3377                    crate::iam::actions::prev_item_roles(self);
3378                } else if self.current_service == Service::IamUserGroups {
3379                    crate::iam::actions::prev_item_groups(self);
3380                }
3381            }
3382            _ => {}
3383        }
3384    }
3385
3386    fn page_down(&mut self) {
3387        if self.current_service == Service::CloudTrailEvents
3388            && self.cloudtrail_state.current_event.is_some()
3389        {
3390            crate::cloudtrail::actions::page_down_fast(self);
3391        } else if self.mode == Mode::ColumnSelector {
3392            let max = self.get_column_selector_max();
3393            let mut next_idx = (self.column_selector_index + 10).min(max);
3394            // Skip blank row if we land on it
3395            if self.is_blank_row_index(next_idx) {
3396                next_idx = (next_idx + 1).min(max);
3397            }
3398            self.column_selector_index = next_idx;
3399        } else if self.mode == Mode::FilterInput && self.current_service == Service::S3Buckets {
3400            crate::s3::actions::page_down_filter_input(self);
3401        } else if self.mode == Mode::FilterInput
3402            && self.current_service == Service::CloudFormationStacks
3403        {
3404            crate::cfn::actions::page_down_filter_input(self);
3405        } else if self.mode == Mode::FilterInput
3406            && self.current_service == Service::IamRoles
3407            && self.iam_state.current_role.is_none()
3408        {
3409            crate::iam::actions::page_down_filter_input_roles(self);
3410        } else if self.mode == Mode::FilterInput
3411            && self.current_service == Service::CloudWatchAlarms
3412        {
3413            crate::cw::actions::alarms_page_down_filter_input(self);
3414        } else if self.mode == Mode::FilterInput
3415            && self.current_service == Service::CloudTrailEvents
3416        {
3417            crate::cloudtrail::actions::page_down_filter_input(self);
3418        } else if self.mode == Mode::FilterInput
3419            && self.current_service == Service::CloudWatchLogGroups
3420        {
3421            crate::cw::actions::logs_page_down_filter_input(self);
3422        } else if self.mode == Mode::FilterInput && self.current_service == Service::LambdaFunctions
3423        {
3424            crate::lambda::functions::page_down_filter_input(self);
3425        } else if self.mode == Mode::FilterInput
3426            && self.current_service == Service::LambdaApplications
3427        {
3428            crate::lambda::applications::page_down_filter_input(self);
3429        } else if self.mode == Mode::FilterInput
3430            && self.current_service == Service::EcrRepositories
3431            && self.ecr_state.current_repository.is_none()
3432        {
3433            crate::ecr::actions::page_down_filter_input(self);
3434        } else if self.mode == Mode::FilterInput && self.current_service == Service::KmsKeys {
3435            crate::kms::actions::page_down_filter_input(self);
3436        } else if self.mode == Mode::FilterInput && self.current_service == Service::EfsFileSystems
3437        {
3438            crate::efs::actions::page_down_filter_input(self);
3439        } else if self.mode == Mode::FilterInput && self.current_service == Service::DynamoDbTables
3440        {
3441            ddb_actions::page_down_filter_input(self);
3442        } else if self.mode == Mode::FilterInput && self.current_service == Service::FsxFileSystems
3443        {
3444            crate::fsx::actions::page_down_filter_input(self);
3445        } else if self.mode == Mode::FilterInput && self.view_mode == ViewMode::PolicyView {
3446            crate::iam::actions::page_down_filter_input_policy_view(self);
3447        } else if self.view_mode == ViewMode::PolicyView {
3448            crate::iam::actions::scroll_down_policy_view(self);
3449        } else if self.current_service == Service::CloudFormationStacks
3450            && self.cfn_state.current_stack.is_some()
3451            && self.cfn_state.detail_tab == CfnDetailTab::Template
3452        {
3453            crate::cfn::actions::scroll_down_template_fast(self);
3454        } else if self.current_service == Service::LambdaFunctions
3455            && self.lambda_state.current_function.is_some()
3456            && self.lambda_state.detail_tab == LambdaDetailTab::Monitor
3457            && !self.lambda_state.is_metrics_loading()
3458        {
3459            crate::lambda::functions::scroll_down(self);
3460        } else if self.current_service == Service::Ec2Instances {
3461            crate::ec2::actions::scroll_down(self);
3462        } else if self.current_service == Service::SqsQueues
3463            && self.sqs_state.current_queue.is_some()
3464        {
3465            crate::sqs::actions::scroll_down_fast(self);
3466        } else if self.current_service == Service::DynamoDbTables
3467            && self.dynamodb_state.current_table.is_some()
3468            && self.dynamodb_state.detail_tab == crate::ui::dynamodb::DetailTab::Monitor
3469            && !self.dynamodb_state.is_metrics_loading()
3470        {
3471            ddb_actions::scroll_down_detail(self);
3472        } else if self.current_service == Service::EfsFileSystems
3473            && self.efs_state.current_file_system.is_some()
3474            && (self.efs_state.detail_tab == crate::ui::efs::DetailTab::Monitoring
3475                || self.efs_state.detail_tab == crate::ui::efs::DetailTab::FileSystemPolicy)
3476            && !self.efs_state.is_metrics_loading()
3477        {
3478            crate::efs::actions::scroll_down_detail(self);
3479        } else if self.current_service == Service::IamRoles
3480            && self.iam_state.current_role.is_some()
3481            && self.iam_state.role_tab == RoleTab::TrustRelationships
3482        {
3483            crate::iam::actions::scroll_down_trust_policy(self);
3484        } else if self.current_service == Service::IamRoles
3485            && self.iam_state.current_role.is_some()
3486            && self.iam_state.role_tab == RoleTab::RevokeSessions
3487        {
3488            crate::iam::actions::scroll_down_revoke_sessions(self);
3489        } else if self.mode == Mode::Normal {
3490            if self.current_service == Service::S3Buckets {
3491                crate::s3::actions::page_down_normal(self);
3492            } else if self.current_service == Service::CloudWatchLogGroups
3493                && self.view_mode == ViewMode::List
3494            {
3495                crate::cw::actions::logs_page_down_normal_list(self);
3496            } else if self.current_service == Service::CloudWatchLogGroups
3497                && self.view_mode == ViewMode::Detail
3498            {
3499                crate::cw::actions::logs_page_down_normal_detail(self);
3500            } else if self.view_mode == ViewMode::Events {
3501                crate::cw::actions::logs_page_down_events(self);
3502            } else if self.view_mode == ViewMode::InsightsResults {
3503                crate::cw::actions::insights_page_down(self);
3504            } else if self.current_service == Service::CloudWatchAlarms {
3505                crate::cw::actions::alarms_page_down_normal(self);
3506            } else if self.current_service == Service::CloudTrailEvents {
3507                crate::cloudtrail::actions::page_down_normal(self);
3508            } else if self.current_service == Service::Ec2Instances {
3509                crate::ec2::actions::page_down_normal(self);
3510            } else if self.current_service == Service::EcrRepositories {
3511                crate::ecr::actions::page_down_normal(self);
3512            } else if self.current_service == Service::KmsKeys {
3513                crate::kms::actions::page_down_normal(self);
3514            } else if self.current_service == Service::EfsFileSystems {
3515                crate::efs::actions::page_down_normal(self);
3516            } else if self.current_service == Service::DynamoDbTables {
3517                ddb_actions::page_down_normal(self)
3518            } else if self.current_service == Service::FsxFileSystems {
3519                crate::fsx::actions::page_down_normal(self);
3520            } else if self.current_service == Service::SqsQueues {
3521                crate::sqs::actions::page_down_normal(self);
3522            } else if self.current_service == Service::LambdaFunctions {
3523                crate::lambda::functions::page_down_normal(self);
3524            } else if self.current_service == Service::LambdaApplications {
3525                crate::lambda::applications::page_down_normal(self);
3526            } else if self.current_service == Service::CloudFormationStacks {
3527                crate::cfn::actions::page_down_normal(self);
3528            } else if self.current_service == Service::IamUsers {
3529                crate::iam::actions::page_down_normal_users(self);
3530            } else if self.current_service == Service::IamRoles {
3531                crate::iam::actions::page_down_normal_roles(self);
3532            } else if self.current_service == Service::IamUserGroups {
3533                crate::iam::actions::page_down_normal_groups(self);
3534            }
3535        }
3536    }
3537
3538    fn page_up(&mut self) {
3539        if self.current_service == Service::CloudTrailEvents
3540            && self.cloudtrail_state.current_event.is_some()
3541        {
3542            crate::cloudtrail::actions::page_up_fast(self);
3543        } else if self.mode == Mode::ColumnSelector {
3544            let mut prev_idx = self.column_selector_index.saturating_sub(10);
3545            // Skip blank row if we land on it
3546            if self.is_blank_row_index(prev_idx) {
3547                prev_idx = prev_idx.saturating_sub(1);
3548            }
3549            self.column_selector_index = prev_idx;
3550        } else if self.mode == Mode::FilterInput && self.current_service == Service::S3Buckets {
3551            crate::s3::actions::page_up_filter_input(self);
3552        } else if self.mode == Mode::FilterInput
3553            && self.current_service == Service::CloudFormationStacks
3554        {
3555            crate::cfn::actions::page_up_filter_input(self);
3556        } else if self.mode == Mode::FilterInput
3557            && self.current_service == Service::IamRoles
3558            && self.iam_state.current_role.is_none()
3559        {
3560            crate::iam::actions::page_up_filter_input_roles(self);
3561        } else if self.mode == Mode::FilterInput
3562            && self.current_service == Service::CloudWatchAlarms
3563        {
3564            crate::cw::actions::alarms_page_up_filter_input(self);
3565        } else if self.mode == Mode::FilterInput
3566            && self.current_service == Service::CloudTrailEvents
3567        {
3568            crate::cloudtrail::actions::page_up_filter_input(self);
3569        } else if self.mode == Mode::FilterInput
3570            && self.current_service == Service::CloudWatchLogGroups
3571        {
3572            crate::cw::actions::logs_page_up_filter_input(self);
3573        } else if self.mode == Mode::FilterInput && self.current_service == Service::LambdaFunctions
3574        {
3575            crate::lambda::functions::page_up_filter_input(self);
3576        } else if self.mode == Mode::FilterInput
3577            && self.current_service == Service::LambdaApplications
3578        {
3579            crate::lambda::applications::page_up_filter_input(self);
3580        } else if self.mode == Mode::FilterInput && self.current_service == Service::KmsKeys {
3581            crate::kms::actions::page_up_filter_input(self);
3582        } else if self.mode == Mode::FilterInput && self.current_service == Service::EfsFileSystems
3583        {
3584            crate::efs::actions::page_up_filter_input(self);
3585        } else if self.mode == Mode::FilterInput && self.current_service == Service::DynamoDbTables
3586        {
3587            ddb_actions::page_up_filter_input(self);
3588        } else if self.mode == Mode::FilterInput && self.current_service == Service::FsxFileSystems
3589        {
3590            crate::fsx::actions::page_up_filter_input(self);
3591        } else if self.mode == Mode::FilterInput
3592            && self.current_service == Service::EcrRepositories
3593            && self.ecr_state.current_repository.is_none()
3594        {
3595            crate::ecr::actions::page_up_filter_input(self);
3596        } else if self.mode == Mode::FilterInput && self.view_mode == ViewMode::PolicyView {
3597            crate::iam::actions::page_up_filter_input_policy_view(self);
3598        } else if self.view_mode == ViewMode::PolicyView {
3599            crate::iam::actions::scroll_up_policy_view(self);
3600        } else if self.current_service == Service::CloudFormationStacks
3601            && self.cfn_state.current_stack.is_some()
3602            && self.cfn_state.detail_tab == CfnDetailTab::Template
3603        {
3604            crate::cfn::actions::scroll_up_template_fast(self);
3605        } else if self.current_service == Service::SqsQueues
3606            && self.sqs_state.current_queue.is_some()
3607        {
3608            crate::sqs::actions::scroll_up_fast(self);
3609        } else if self.current_service == Service::DynamoDbTables
3610            && self.dynamodb_state.current_table.is_some()
3611            && self.dynamodb_state.detail_tab == crate::ui::dynamodb::DetailTab::Monitor
3612            && !self.dynamodb_state.is_metrics_loading()
3613        {
3614            ddb_actions::scroll_up_detail(self);
3615        } else if self.current_service == Service::EfsFileSystems
3616            && self.efs_state.current_file_system.is_some()
3617            && (self.efs_state.detail_tab == crate::ui::efs::DetailTab::Monitoring
3618                || self.efs_state.detail_tab == crate::ui::efs::DetailTab::FileSystemPolicy)
3619            && !self.efs_state.is_metrics_loading()
3620        {
3621            crate::efs::actions::scroll_up_detail(self);
3622        } else if self.current_service == Service::IamRoles
3623            && self.iam_state.current_role.is_some()
3624            && self.iam_state.role_tab == RoleTab::TrustRelationships
3625        {
3626            crate::iam::actions::scroll_up_trust_policy(self);
3627        } else if self.current_service == Service::IamRoles
3628            && self.iam_state.current_role.is_some()
3629            && self.iam_state.role_tab == RoleTab::RevokeSessions
3630        {
3631            crate::iam::actions::scroll_up_revoke_sessions(self);
3632        } else if self.mode == Mode::Normal {
3633            if self.current_service == Service::S3Buckets {
3634                crate::s3::actions::page_up_normal(self);
3635            } else if self.current_service == Service::CloudWatchLogGroups
3636                && self.view_mode == ViewMode::List
3637            {
3638                crate::cw::actions::logs_page_up_normal_list(self);
3639            } else if self.current_service == Service::CloudWatchLogGroups
3640                && self.view_mode == ViewMode::Detail
3641            {
3642                crate::cw::actions::logs_page_up_normal_detail(self);
3643            } else if self.view_mode == ViewMode::Events {
3644                crate::cw::actions::logs_page_up_events(self);
3645            } else if self.view_mode == ViewMode::InsightsResults {
3646                crate::cw::actions::insights_page_up(self);
3647            } else if self.current_service == Service::CloudWatchAlarms {
3648                crate::cw::actions::alarms_page_up_normal(self);
3649            } else if self.current_service == Service::CloudTrailEvents {
3650                crate::cloudtrail::actions::page_up_normal(self);
3651            } else if self.current_service == Service::KmsKeys {
3652                crate::kms::actions::page_up_normal(self);
3653            } else if self.current_service == Service::EfsFileSystems {
3654                crate::efs::actions::page_up_normal(self);
3655            } else if self.current_service == Service::DynamoDbTables {
3656                ddb_actions::page_up_normal(self)
3657            } else if self.current_service == Service::FsxFileSystems {
3658                crate::fsx::actions::page_up_normal(self);
3659            } else if self.current_service == Service::Ec2Instances {
3660                crate::ec2::actions::page_up_normal(self);
3661            } else if self.current_service == Service::EcrRepositories {
3662                crate::ecr::actions::page_up_normal(self);
3663            } else if self.current_service == Service::SqsQueues {
3664                self.sqs_state.queues.page_up();
3665            } else if self.current_service == Service::LambdaFunctions {
3666                crate::lambda::functions::page_up_normal(self);
3667            } else if self.current_service == Service::LambdaApplications {
3668                crate::lambda::applications::page_up_normal(self);
3669            } else if self.current_service == Service::CloudFormationStacks {
3670                crate::cfn::actions::page_up_normal(self);
3671            } else if self.current_service == Service::IamUsers {
3672                crate::iam::actions::page_up_normal_users(self);
3673            } else if self.current_service == Service::IamRoles {
3674                crate::iam::actions::page_up_normal_roles(self);
3675            }
3676        }
3677    }
3678
3679    fn next_pane(&mut self) {
3680        if self.current_service == Service::S3Buckets {
3681            crate::s3::actions::expand_row(self);
3682        } else if self.view_mode == ViewMode::InsightsResults {
3683            // Right arrow scrolls horizontally by 1 column
3684            let max_cols = self
3685                .insights_state
3686                .insights
3687                .query_results
3688                .first()
3689                .map(|r| r.len())
3690                .unwrap_or(0);
3691            if self.insights_state.insights.results_horizontal_scroll < max_cols.saturating_sub(1) {
3692                self.insights_state.insights.results_horizontal_scroll += 1;
3693            }
3694        } else if self.current_service == Service::CloudWatchLogGroups
3695            || self.view_mode == ViewMode::Events
3696        {
3697            crate::cw::actions::logs_expand_row(self);
3698        } else if self.current_service == Service::CloudWatchAlarms {
3699            crate::cw::actions::alarms_expand_row(self);
3700        } else if self.current_service == Service::Ec2Instances {
3701            crate::ec2::actions::expand_row(self);
3702        } else if self.current_service == Service::EcrRepositories {
3703            crate::ecr::actions::next_pane(self);
3704        } else if self.current_service == Service::KmsKeys {
3705            crate::kms::actions::expand_row(self);
3706        } else if self.current_service == Service::EfsFileSystems {
3707            crate::efs::actions::expand_row(self);
3708        } else if self.current_service == Service::DynamoDbTables {
3709            ddb_actions::expand_row(self)
3710        } else if self.current_service == Service::FsxFileSystems {
3711            crate::fsx::actions::expand_row(self);
3712        } else if self.current_service == Service::SqsQueues {
3713            crate::sqs::actions::expand_row(self);
3714        } else if self.current_service == Service::LambdaFunctions {
3715            crate::lambda::functions::expand_row(self);
3716        } else if self.current_service == Service::LambdaApplications {
3717            crate::lambda::applications::expand_row(self);
3718        } else if self.current_service == Service::CloudFormationStacks
3719            && self.cfn_state.current_stack.is_none()
3720        {
3721            self.cfn_state.table.toggle_expand();
3722        } else if self.current_service == Service::CloudFormationStacks
3723            && self.cfn_state.current_stack.is_some()
3724            && self.cfn_state.detail_tab == CfnDetailTab::Parameters
3725        {
3726            self.cfn_state.parameters.toggle_expand();
3727        } else if self.current_service == Service::CloudFormationStacks
3728            && self.cfn_state.current_stack.is_some()
3729            && self.cfn_state.detail_tab == CfnDetailTab::Outputs
3730        {
3731            self.cfn_state.outputs.toggle_expand();
3732        } else if self.current_service == Service::CloudFormationStacks
3733            && self.cfn_state.current_stack.is_some()
3734            && self.cfn_state.detail_tab == CfnDetailTab::Resources
3735        {
3736            self.cfn_state.resources.toggle_expand();
3737        } else if self.current_service == Service::IamUsers {
3738            crate::iam::actions::expand_row_users(self);
3739        } else if self.current_service == Service::IamRoles {
3740            crate::iam::actions::expand_row_roles(self);
3741        } else if self.current_service == Service::IamUserGroups {
3742            crate::iam::actions::expand_row_groups(self);
3743        }
3744    }
3745
3746    fn go_to_page(&mut self, page: usize) {
3747        if self.current_service == Service::VpcVpcs {
3748            vpc_actions::go_to_page(self, page);
3749            return;
3750        }
3751        if self.current_service == Service::RdsInstances {
3752            rds_actions::go_to_page(self, page);
3753            return;
3754        }
3755        if page == 0 {
3756            return;
3757        }
3758
3759        match self.current_service {
3760            Service::CloudWatchAlarms => {
3761                crate::cw::actions::alarms_go_to_page(self, page);
3762            }
3763            Service::CloudTrailEvents => {
3764                let page_size = self.cloudtrail_state.table.page_size.value();
3765                let filtered_count = self.cloudtrail_state.table.items.len();
3766                let max_page = (filtered_count / page_size) + 1; // Allow one page beyond loaded
3767
3768                // Only navigate if page is within valid range
3769                if page <= max_page {
3770                    let target = (page - 1) * page_size;
3771                    self.cloudtrail_state.table.scroll_offset = target;
3772                    self.cloudtrail_state.table.selected = target;
3773                    self.cloudtrail_state.table.expanded_item = None; // Reset expansion
3774                }
3775                // Otherwise do nothing (ignore invalid page numbers)
3776            }
3777            Service::CloudWatchLogGroups => {
3778                crate::cw::actions::logs_go_to_page(self, page);
3779            }
3780            Service::EcrRepositories => {
3781                crate::ecr::actions::go_to_page(self, page);
3782            }
3783            Service::KmsKeys => {
3784                crate::kms::actions::go_to_page(self, page);
3785            }
3786            Service::EfsFileSystems => {
3787                crate::efs::actions::go_to_page(self, page);
3788            }
3789            Service::DynamoDbTables => {
3790                ddb_actions::go_to_page(self, page);
3791            }
3792            Service::FsxFileSystems => {
3793                crate::fsx::actions::go_to_page(self, page);
3794            }
3795            Service::SqsQueues => {
3796                crate::sqs::actions::go_to_page(self, page);
3797            }
3798            Service::S3Buckets => {
3799                crate::s3::actions::go_to_page(self, page);
3800            }
3801            Service::LambdaFunctions => {
3802                crate::lambda::functions::go_to_page(self, page);
3803            }
3804            Service::LambdaApplications => {
3805                crate::lambda::applications::go_to_page(self, page);
3806            }
3807            Service::CloudFormationStacks => {
3808                crate::cfn::actions::go_to_page(self, page);
3809            }
3810            Service::IamUsers => {
3811                crate::iam::actions::go_to_page_users(self, page);
3812            }
3813            Service::IamRoles => {
3814                crate::iam::actions::go_to_page_roles(self, page);
3815            }
3816            _ => {}
3817        }
3818    }
3819
3820    fn prev_pane(&mut self) {
3821        if self.current_service == Service::S3Buckets {
3822            crate::s3::actions::prev_pane(self);
3823        } else if self.view_mode == ViewMode::InsightsResults {
3824            // Left arrow scrolls horizontally by 1 column
3825            self.insights_state.insights.results_horizontal_scroll = self
3826                .insights_state
3827                .insights
3828                .results_horizontal_scroll
3829                .saturating_sub(1);
3830        } else if self.current_service == Service::CloudWatchLogGroups
3831            || self.view_mode == ViewMode::Events
3832        {
3833            crate::cw::actions::logs_prev_pane(self);
3834        } else if self.current_service == Service::CloudWatchAlarms {
3835            crate::cw::actions::alarms_prev_pane(self);
3836        } else if self.current_service == Service::Ec2Instances {
3837            crate::ec2::actions::prev_pane(self);
3838        } else if self.current_service == Service::ApiGatewayApis {
3839            crate::apig::actions::prev_pane(self);
3840        } else if self.current_service == Service::EcrRepositories {
3841            crate::ecr::actions::prev_pane(self);
3842        } else if self.current_service == Service::KmsKeys {
3843            crate::kms::actions::prev_pane(self);
3844        } else if self.current_service == Service::EfsFileSystems {
3845            crate::efs::actions::prev_pane(self);
3846        } else if self.current_service == Service::DynamoDbTables {
3847            ddb_actions::prev_pane(self)
3848        } else if self.current_service == Service::FsxFileSystems {
3849            crate::fsx::actions::prev_pane(self);
3850        } else if self.current_service == Service::SqsQueues {
3851            crate::sqs::actions::prev_pane(self);
3852        } else if self.current_service == Service::LambdaFunctions {
3853            crate::lambda::functions::prev_pane(self);
3854        } else if self.current_service == Service::LambdaApplications {
3855            crate::lambda::applications::prev_pane(self);
3856        } else if self.current_service == Service::CloudFormationStacks {
3857            crate::cfn::actions::prev_pane(self);
3858        } else if self.current_service == Service::IamUsers {
3859            crate::iam::actions::prev_pane_users(self);
3860        } else if self.current_service == Service::IamRoles {
3861            crate::iam::actions::prev_pane_roles(self);
3862        } else if self.current_service == Service::IamUserGroups {
3863            crate::iam::actions::prev_pane_groups(self);
3864        }
3865    }
3866
3867    fn collapse_row(&mut self) {
3868        match self.current_service {
3869            Service::S3Buckets => {
3870                crate::s3::actions::collapse_row(self);
3871            }
3872            Service::CloudWatchLogGroups => {
3873                crate::cw::actions::logs_collapse_row(self);
3874            }
3875            Service::CloudWatchAlarms => crate::cw::actions::alarms_collapse_row(self),
3876            Service::Ec2Instances => {
3877                crate::ec2::actions::collapse_row(self);
3878            }
3879            Service::EcrRepositories => {
3880                crate::ecr::actions::collapse_row(self);
3881            }
3882            Service::LambdaFunctions => crate::lambda::functions::collapse_row(self),
3883            Service::LambdaApplications => crate::lambda::applications::prev_pane(self),
3884            Service::SqsQueues => crate::sqs::actions::collapse_row(self),
3885            Service::CloudFormationStacks => {
3886                crate::cfn::actions::collapse_row(self);
3887            }
3888            Service::IamUsers => {
3889                crate::iam::actions::collapse_row_users(self);
3890            }
3891            Service::IamRoles => {
3892                crate::iam::actions::collapse_row_roles(self);
3893            }
3894            Service::IamUserGroups => {
3895                crate::iam::actions::collapse_row_groups(self);
3896            }
3897            Service::ApiGatewayApis => {
3898                crate::apig::actions::collapse_row(self);
3899            }
3900            Service::KmsKeys => {
3901                crate::kms::actions::collapse_row(self);
3902            }
3903            Service::EfsFileSystems => {
3904                crate::efs::actions::collapse_row(self);
3905            }
3906            Service::DynamoDbTables => {
3907                ddb_actions::collapse_row(self);
3908            }
3909            Service::FsxFileSystems => {
3910                crate::fsx::actions::collapse_row(self);
3911            }
3912            Service::CloudTrailEvents => {
3913                if self.cloudtrail_state.current_event.is_some()
3914                    && self.cloudtrail_state.detail_focus == CloudTrailDetailFocus::Resources
3915                {
3916                    // Collapse resources table column
3917                    self.cloudtrail_state.resources_expanded_index = None;
3918                } else {
3919                    self.cloudtrail_state.table.collapse();
3920                }
3921            }
3922            _ => {}
3923        }
3924    }
3925
3926    fn expand_row(&mut self) {
3927        match self.current_service {
3928            Service::S3Buckets => {
3929                crate::s3::actions::expand_row_left(self);
3930            }
3931            Service::ApiGatewayApis => {
3932                crate::apig::actions::expand_row(self);
3933            }
3934            Service::CloudTrailEvents => {
3935                if self.cloudtrail_state.current_event.is_some()
3936                    && self.cloudtrail_state.detail_focus == CloudTrailDetailFocus::Resources
3937                {
3938                    // Expand resources table column
3939                    self.cloudtrail_state.resources_expanded_index = Some(0);
3940                } else {
3941                    self.cloudtrail_state.table.expand();
3942                }
3943            }
3944            Service::CloudFormationStacks => {
3945                crate::cfn::actions::expand_row(self);
3946            }
3947            _ => {
3948                // For other services, Right arrow switches panes
3949                self.next_pane();
3950            }
3951        }
3952    }
3953
3954    fn select_item(&mut self) {
3955        if self.mode == Mode::RegionPicker {
3956            // If filter is active (INSERT mode), exit filter mode instead of selecting
3957            if self.region_filter_active {
3958                self.region_filter_active = false;
3959                return;
3960            }
3961            let filtered = self.get_filtered_regions();
3962            if let Some(region) = filtered.get(self.region_picker_selected) {
3963                // Save current session before changing region
3964                if !self.tabs.is_empty() {
3965                    let mut session = Session::new(
3966                        self.profile.clone(),
3967                        self.region.clone(),
3968                        self.config.account_id.clone(),
3969                        self.config.role_arn.clone(),
3970                    );
3971
3972                    for tab in &self.tabs {
3973                        session.tabs.push(SessionTab {
3974                            service: format!("{:?}", tab.service),
3975                            title: tab.title.clone(),
3976                            breadcrumb: tab.breadcrumb.clone(),
3977                            filter: None,
3978                            selected_item: None,
3979                        });
3980                    }
3981
3982                    let _ = session.save();
3983                }
3984
3985                self.region = region.code.to_string();
3986                self.config.region = region.code.to_string();
3987
3988                // Close all tabs - region change invalidates all data
3989                self.tabs.clear();
3990                self.current_tab = 0;
3991                self.service_selected = false;
3992
3993                self.mode = Mode::Normal;
3994            }
3995        } else if self.mode == Mode::ProfilePicker {
3996            let filtered = self.get_filtered_profiles();
3997            if let Some(profile) = filtered.get(self.profile_picker_selected) {
3998                let profile_name = profile.name.clone();
3999                let profile_region = profile.region.clone();
4000
4001                self.profile = profile_name.clone();
4002                std::env::set_var("AWS_PROFILE", &profile_name);
4003
4004                // Use profile's region if available
4005                if let Some(region) = profile_region {
4006                    self.region = region;
4007                }
4008
4009                self.mode = Mode::Normal;
4010                // Note: Changing profile requires reconnecting to AWS
4011            }
4012        } else if self.mode == Mode::ServicePicker {
4013            // If filter is active (INSERT mode), exit filter mode instead of selecting
4014            if self.service_picker.filter_active {
4015                self.service_picker.filter_active = false;
4016                return;
4017            }
4018
4019            let filtered = self.filtered_services();
4020            if let Some(&service) = filtered.get(self.service_picker.selected) {
4021                let new_service = match service {
4022                    "API Gateway › APIs" => Service::ApiGatewayApis,
4023                    "CloudWatch › Log Groups" => Service::CloudWatchLogGroups,
4024                    "CloudWatch › Logs Insights" => Service::CloudWatchInsights,
4025                    "CloudWatch › Alarms" => Service::CloudWatchAlarms,
4026                    "CloudTrail › Event History" => Service::CloudTrailEvents,
4027                    "CloudFormation › Stacks" => Service::CloudFormationStacks,
4028                    "EC2 › Instances" => Service::Ec2Instances,
4029                    "ECR › Repositories" => Service::EcrRepositories,
4030                    "KMS › Managed Keys" => Service::KmsKeys,
4031                    "EFS › File Systems" => Service::EfsFileSystems,
4032                    "FSx › File Systems" => Service::FsxFileSystems,
4033                    "RDS › Databases" | "RDS › DB Instances" => Service::RdsInstances,
4034                    "VPC › VPCs" => Service::VpcVpcs,
4035                    "DynamoDB › Tables" => Service::DynamoDbTables,
4036                    "IAM › Users" => Service::IamUsers,
4037                    "IAM › Roles" => Service::IamRoles,
4038                    "IAM › User Groups" => Service::IamUserGroups,
4039                    "Lambda › Functions" => Service::LambdaFunctions,
4040                    "Lambda › Applications" => Service::LambdaApplications,
4041                    "S3 › Buckets" => Service::S3Buckets,
4042                    "SQS › Queues" => Service::SqsQueues,
4043                    _ => return,
4044                };
4045
4046                // Create new tab
4047                self.tabs.push(Tab {
4048                    service: new_service,
4049                    title: service.to_string(),
4050                    breadcrumb: service.to_string(),
4051                });
4052                self.current_tab = self.tabs.len() - 1;
4053                self.current_service = new_service;
4054                if new_service == Service::VpcVpcs {
4055                    self.vpc_state.vpcs.reset();
4056                    self.vpc_state.vpcs.loading = true;
4057                }
4058                if new_service == Service::RdsInstances {
4059                    self.rds_state.instances.reset();
4060                    self.rds_state.instances.loading = true;
4061                }
4062                self.view_mode = ViewMode::List;
4063                self.service_selected = true;
4064                self.mode = Mode::Normal;
4065            }
4066        } else if self.mode == Mode::TabPicker {
4067            let filtered = self.get_filtered_tabs();
4068            if let Some(&(idx, _)) = filtered.get(self.tab_picker_selected) {
4069                self.current_tab = idx;
4070                self.current_service = self.tabs[idx].service;
4071                self.mode = Mode::Normal;
4072                self.tab_filter.clear();
4073            }
4074        } else if self.mode == Mode::SessionPicker {
4075            let filtered = self.get_filtered_sessions();
4076            if let Some(&session) = filtered.get(self.session_picker_selected) {
4077                let session = session.clone();
4078
4079                // Load the selected session
4080                self.current_session = Some(session.clone());
4081                self.profile = session.profile.clone();
4082                self.region = session.region.clone();
4083                self.config.region = session.region.clone();
4084                self.config.account_id = session.account_id.clone();
4085                self.config.role_arn = session.role_arn.clone();
4086
4087                // Restore tabs
4088                self.tabs = session
4089                    .tabs
4090                    .iter()
4091                    .map(|st| Tab {
4092                        service: match st.service.as_str() {
4093                            "RdsInstances" => Service::RdsInstances,
4094                            "VpcVpcs" => Service::VpcVpcs,
4095                            "CloudWatchLogGroups" => Service::CloudWatchLogGroups,
4096                            "CloudWatchInsights" => Service::CloudWatchInsights,
4097                            "CloudWatchAlarms" => Service::CloudWatchAlarms,
4098                            "S3Buckets" => Service::S3Buckets,
4099                            "CloudTrailEvents" => Service::CloudTrailEvents,
4100                            "SqsQueues" => Service::SqsQueues,
4101                            _ => Service::CloudWatchLogGroups,
4102                        },
4103                        title: if st.service == "RdsInstances" {
4104                            Service::RdsInstances.name().into()
4105                        } else {
4106                            st.title.clone()
4107                        },
4108                        breadcrumb: if st.service == "RdsInstances" {
4109                            Service::RdsInstances.name().into()
4110                        } else {
4111                            st.breadcrumb.clone()
4112                        },
4113                    })
4114                    .collect();
4115
4116                if !self.tabs.is_empty() {
4117                    self.current_tab = 0;
4118                    self.current_service = self.tabs[0].service;
4119                    self.service_selected = true;
4120                }
4121
4122                self.mode = Mode::Normal;
4123            }
4124        } else if self.mode == Mode::InsightsInput {
4125            // In InsightsInput mode, behavior depends on focus
4126            use crate::app::InsightsFocus;
4127            match self.insights_state.insights.insights_focus {
4128                InsightsFocus::Query => {
4129                    // Add newline to query
4130                    self.insights_state.insights.query_text.push('\n');
4131                    self.insights_state.insights.query_cursor_line += 1;
4132                    self.insights_state.insights.query_cursor_col = 0;
4133                }
4134                InsightsFocus::LogGroupSearch => {
4135                    // Toggle dropdown — when opening with empty search, show all log groups
4136                    let was_open = self.insights_state.insights.show_dropdown;
4137                    self.insights_state.insights.show_dropdown = !was_open;
4138                    if !was_open {
4139                        // Opening: populate matches with all available log groups if no search text
4140                        if self.insights_state.insights.log_group_search.is_empty() {
4141                            self.insights_state.insights.log_group_matches = self
4142                                .log_groups_state
4143                                .log_groups
4144                                .items
4145                                .iter()
4146                                .take(50)
4147                                .map(|g| g.name.clone())
4148                                .collect();
4149                        }
4150                        self.insights_state.insights.dropdown_selected = 0;
4151                    }
4152                }
4153                _ => {}
4154            }
4155        } else if self.mode == Mode::Normal {
4156            // If no service selected, select from service picker
4157            if !self.service_selected {
4158                let filtered = self.filtered_services();
4159                if let Some(&service) = filtered.get(self.service_picker.selected) {
4160                    match service {
4161                        "CloudWatch › Log Groups" => {
4162                            self.current_service = Service::CloudWatchLogGroups;
4163                            self.view_mode = ViewMode::List;
4164                            self.service_selected = true;
4165                        }
4166                        "CloudWatch › Logs Insights" => {
4167                            self.current_service = Service::CloudWatchInsights;
4168                            self.view_mode = ViewMode::InsightsResults;
4169                            self.service_selected = true;
4170                        }
4171                        "CloudWatch › Alarms" => {
4172                            self.current_service = Service::CloudWatchAlarms;
4173                            self.view_mode = ViewMode::List;
4174                            self.service_selected = true;
4175                        }
4176                        "S3 › Buckets" => {
4177                            self.current_service = Service::S3Buckets;
4178                            self.view_mode = ViewMode::List;
4179                            self.service_selected = true;
4180                        }
4181                        "EC2 › Instances" => {
4182                            self.current_service = Service::Ec2Instances;
4183                            self.view_mode = ViewMode::List;
4184                            self.service_selected = true;
4185                        }
4186                        "ECR › Repositories" => {
4187                            self.current_service = Service::EcrRepositories;
4188                            self.view_mode = ViewMode::List;
4189                            self.service_selected = true;
4190                        }
4191                        "Lambda › Functions" => {
4192                            self.current_service = Service::LambdaFunctions;
4193                            self.view_mode = ViewMode::List;
4194                            self.service_selected = true;
4195                        }
4196                        "Lambda › Applications" => {
4197                            self.current_service = Service::LambdaApplications;
4198                            self.view_mode = ViewMode::List;
4199                            self.service_selected = true;
4200                        }
4201                        _ => {}
4202                    }
4203                }
4204                return;
4205            }
4206
4207            // Select in content area
4208            if self.view_mode == ViewMode::InsightsResults {
4209                // Toggle expand for selected result
4210                if self.insights_state.insights.expanded_result
4211                    == Some(self.insights_state.insights.results_selected)
4212                {
4213                    self.insights_state.insights.expanded_result = None;
4214                } else {
4215                    self.insights_state.insights.expanded_result =
4216                        Some(self.insights_state.insights.results_selected);
4217                }
4218            } else if self.current_service == Service::S3Buckets {
4219                crate::s3::actions::select_item(self);
4220            } else if self.current_service == Service::ApiGatewayApis {
4221                crate::apig::actions::select_item(self);
4222            } else if self.current_service == Service::CloudFormationStacks {
4223                if self.cfn_state.current_stack.is_none() {
4224                    // Drill into stack detail view
4225                    let filtered_stacks = filtered_cloudformation_stacks(self);
4226                    if let Some(stack) = self.cfn_state.table.get_selected(&filtered_stacks) {
4227                        let stack_name = stack.name.clone();
4228                        let mut tags = stack.tags.clone();
4229                        tags.sort_by(|a, b| a.0.cmp(&b.0));
4230
4231                        self.cfn_state.current_stack = Some(stack_name);
4232                        self.cfn_state.tags.items = tags;
4233                        self.cfn_state.tags.reset();
4234                        self.cfn_state.table.loading = true;
4235                        self.update_current_tab_breadcrumb();
4236                    }
4237                }
4238            } else if self.current_service == Service::CloudWatchAlarms {
4239                crate::cw::actions::alarms_select_item(self);
4240            } else if self.current_service == Service::CloudTrailEvents {
4241                crate::cloudtrail::actions::select_item(self);
4242            } else if self.current_service == Service::EcrRepositories {
4243                crate::ecr::actions::select_item(self);
4244            } else if self.current_service == Service::KmsKeys {
4245                // KMS keys list — no drill-down yet; tab switch on Enter
4246            } else if self.current_service == Service::EfsFileSystems {
4247                crate::efs::actions::select_item(self);
4248            } else if self.current_service == Service::DynamoDbTables {
4249                ddb_actions::select_item(self)
4250            } else if self.current_service == Service::FsxFileSystems {
4251                crate::fsx::actions::select_item(self);
4252            } else if self.current_service == Service::Ec2Instances {
4253                crate::ec2::actions::select_item(self);
4254            } else if self.current_service == Service::SqsQueues {
4255                crate::sqs::actions::select_item(self);
4256            } else if self.current_service == Service::IamUsers {
4257                crate::iam::actions::select_item_users(self);
4258            } else if self.current_service == Service::IamRoles {
4259                crate::iam::actions::select_item_roles(self);
4260            } else if self.current_service == Service::IamUserGroups {
4261                crate::iam::actions::select_item_groups(self);
4262            } else if self.current_service == Service::LambdaFunctions {
4263                crate::lambda::functions::select_item(self);
4264            } else if self.current_service == Service::LambdaApplications {
4265                crate::lambda::applications::select_item(self);
4266            } else if self.current_service == Service::CloudWatchLogGroups {
4267                crate::cw::actions::logs_select_item(self);
4268            } else if self.current_service == Service::CloudWatchAlarms
4269                && self.view_mode != ViewMode::Detail
4270            {
4271                crate::cw::actions::alarms_select_expand(self);
4272            } else if self.current_service == Service::CloudWatchInsights {
4273                // In Normal mode, Enter always executes query
4274                if !self.insights_state.insights.selected_log_groups.is_empty() {
4275                    self.log_groups_state.loading = true;
4276                    self.insights_state.insights.query_completed = true;
4277                }
4278            }
4279        }
4280    }
4281
4282    pub async fn load_log_groups(&mut self) -> anyhow::Result<()> {
4283        self.log_groups_state.log_groups.items = self.cloudwatch_client.list_log_groups().await?;
4284        Ok(())
4285    }
4286
4287    pub async fn load_alarms(&mut self) -> anyhow::Result<()> {
4288        let alarm_data = self.alarms_client.list_alarms().await?;
4289        self.alarms_state.table.items = alarm_data
4290            .into_iter()
4291            .map(|d| Alarm {
4292                name: d.name,
4293                state: d.state,
4294                state_updated_timestamp: d.state_updated,
4295                description: d.description,
4296                metric_name: d.metric_name,
4297                namespace: d.namespace,
4298                statistic: d.statistic,
4299                period: d.period,
4300                comparison_operator: d.comparison,
4301                threshold: d.threshold,
4302                actions_enabled: d.actions_enabled,
4303                state_reason: d.state_reason,
4304                resource: d.resource,
4305                dimensions: d.dimensions,
4306                expression: d.expression,
4307                alarm_type: d.alarm_type,
4308                cross_account: d.cross_account,
4309                alarm_arn: d.alarm_arn,
4310                datapoints_to_alarm: d.datapoints_to_alarm,
4311                evaluation_periods: d.evaluation_periods,
4312                treat_missing_data: d.treat_missing_data,
4313                evaluate_low_sample_percentile: d.evaluate_low_sample_percentile,
4314                sub_metrics: d.sub_metrics,
4315            })
4316            .collect();
4317        Ok(())
4318    }
4319
4320    /// Fetch metric data for a metric math alarm using GetMetricData.
4321    pub async fn load_alarm_metric_math_data(&mut self, alarm_name: &str) -> anyhow::Result<()> {
4322        if let Some(alarm) = self
4323            .alarms_state
4324            .table
4325            .items
4326            .iter()
4327            .find(|a| a.name == alarm_name)
4328        {
4329            if alarm.sub_metrics.is_empty() {
4330                return Ok(());
4331            }
4332            let sub_metrics = alarm.sub_metrics.clone();
4333            let end_time = chrono::Utc::now();
4334            let start_time = end_time - chrono::TimeDelta::hours(72);
4335            let data = self
4336                .alarms_client
4337                .get_metric_math_data(&sub_metrics, start_time, end_time)
4338                .await?;
4339            self.alarms_state.metric_data = data;
4340        }
4341        Ok(())
4342    }
4343
4344    pub async fn load_cloudtrail_events(&mut self) -> anyhow::Result<()> {
4345        let filter = self.cloudtrail_state.active_event_name_filter.clone();
4346        let (events, next_token) = self
4347            .cloudtrail_client
4348            .lookup_events(None, None, filter)
4349            .await?;
4350        self.cloudtrail_state.table.items = events
4351            .into_iter()
4352            .map(
4353                |(
4354                    event_name,
4355                    event_time,
4356                    username,
4357                    event_source,
4358                    resource_type,
4359                    resource_name,
4360                    read_only,
4361                    aws_region,
4362                    event_id,
4363                    access_key_id,
4364                    source_ip_address,
4365                    error_code,
4366                    request_id,
4367                    event_type,
4368                    cloud_trail_event_json,
4369                )| CloudTrailEvent {
4370                    event_name,
4371                    event_time,
4372                    username,
4373                    event_source,
4374                    resource_type,
4375                    resource_name,
4376                    read_only,
4377                    aws_region,
4378                    event_id,
4379                    access_key_id,
4380                    source_ip_address,
4381                    error_code,
4382                    request_id,
4383                    event_type,
4384                    cloud_trail_event_json,
4385                },
4386            )
4387            .collect();
4388        self.cloudtrail_state.table.next_token = next_token;
4389        Ok(())
4390    }
4391
4392    pub async fn load_more_cloudtrail_events(&mut self) -> anyhow::Result<()> {
4393        if let Some(token) = self.cloudtrail_state.table.next_token.clone() {
4394            let filter = self.cloudtrail_state.active_event_name_filter.clone();
4395            // Just load the next batch of events
4396            let (events, next_token) = self
4397                .cloudtrail_client
4398                .lookup_events(None, Some(token), filter)
4399                .await?;
4400            self.cloudtrail_state
4401                .table
4402                .items
4403                .extend(events.into_iter().map(
4404                    |(
4405                        event_name,
4406                        event_time,
4407                        username,
4408                        event_source,
4409                        resource_type,
4410                        resource_name,
4411                        read_only,
4412                        aws_region,
4413                        event_id,
4414                        access_key_id,
4415                        source_ip_address,
4416                        error_code,
4417                        request_id,
4418                        event_type,
4419                        cloud_trail_event_json,
4420                    )| CloudTrailEvent {
4421                        event_name,
4422                        event_time,
4423                        username,
4424                        event_source,
4425                        resource_type,
4426                        resource_name,
4427                        read_only,
4428                        aws_region,
4429                        event_id,
4430                        access_key_id,
4431                        source_ip_address,
4432                        error_code,
4433                        request_id,
4434                        event_type,
4435                        cloud_trail_event_json,
4436                    },
4437                ));
4438            self.cloudtrail_state.table.next_token = next_token;
4439        }
4440        Ok(())
4441    }
4442
4443    pub async fn load_s3_objects(&mut self) -> anyhow::Result<()> {
4444        if let Some(bucket_name) = &self.s3_state.current_bucket {
4445            // Get or fetch bucket region
4446            let bucket_region = if let Some(bucket) = self
4447                .s3_state
4448                .buckets
4449                .items
4450                .iter_mut()
4451                .find(|b| &b.name == bucket_name)
4452            {
4453                if bucket.region.is_empty() {
4454                    // Fetch the actual region
4455                    let region = self.s3_client.get_bucket_location(bucket_name).await?;
4456                    bucket.region = region.clone();
4457                    region
4458                } else {
4459                    bucket.region.clone()
4460                }
4461            } else {
4462                self.config.region.clone()
4463            };
4464
4465            let prefix = self
4466                .s3_state
4467                .prefix_stack
4468                .last()
4469                .cloned()
4470                .unwrap_or_default();
4471            let objects = self
4472                .s3_client
4473                .list_objects(bucket_name, &bucket_region, &prefix)
4474                .await?;
4475            self.s3_state.objects = objects
4476                .into_iter()
4477                .map(|(key, size, modified, is_prefix, storage_class)| S3Object {
4478                    key,
4479                    size,
4480                    last_modified: modified,
4481                    is_prefix,
4482                    storage_class,
4483                })
4484                .collect();
4485            self.s3_state.selected_object = 0;
4486        }
4487        Ok(())
4488    }
4489
4490    pub async fn load_bucket_preview(&mut self, bucket_name: String) -> anyhow::Result<()> {
4491        let stored_region = self
4492            .s3_state
4493            .buckets
4494            .items
4495            .iter()
4496            .find(|b| b.name == bucket_name)
4497            .and_then(|b| {
4498                if b.region.is_empty() {
4499                    None
4500                } else {
4501                    Some(b.region.clone())
4502                }
4503            });
4504
4505        let bucket_region = match stored_region {
4506            Some(r) => r,
4507            None => {
4508                let r = self.s3_client.get_bucket_location(&bucket_name).await?;
4509                // Cache the discovered region back into the bucket
4510                if let Some(b) = self
4511                    .s3_state
4512                    .buckets
4513                    .items
4514                    .iter_mut()
4515                    .find(|b| b.name == bucket_name)
4516                {
4517                    b.region = r.clone();
4518                }
4519                r
4520            }
4521        };
4522        let objects = self
4523            .s3_client
4524            .list_objects(&bucket_name, &bucket_region, "")
4525            .await?;
4526        let preview: Vec<S3Object> = objects
4527            .into_iter()
4528            .map(|(key, size, modified, is_prefix, storage_class)| S3Object {
4529                key,
4530                size,
4531                last_modified: modified,
4532                is_prefix,
4533                storage_class,
4534            })
4535            .collect();
4536        self.s3_state
4537            .bucket_preview
4538            .insert(bucket_name.clone(), preview);
4539        self.after_bucket_preview_loaded(&bucket_name);
4540        Ok(())
4541    }
4542
4543    /// Called after a bucket's preview is loaded. If the bucket is the currently
4544    /// selected bucket (selection is on the bucket row itself), advance selection
4545    /// to the first child row so the user lands inside the bucket automatically.
4546    pub fn after_bucket_preview_loaded(&mut self, bucket_name: &str) {
4547        if self.s3_state.current_bucket.is_some() {
4548            return; // inside a bucket view, not the bucket list
4549        }
4550        // Find the row index of this bucket in the current view
4551        let mut row_idx = 0usize;
4552        for b in &self.s3_state.buckets.items {
4553            if b.name == bucket_name {
4554                // Only advance if selection is sitting on this exact bucket row
4555                if self.s3_state.selected_row == row_idx {
4556                    if let Some(preview) = self.s3_state.bucket_preview.get(bucket_name) {
4557                        if !preview.is_empty() {
4558                            self.s3_state.selected_row = row_idx + 1;
4559                            let visible = self.s3_state.bucket_visible_rows.get();
4560                            if self.s3_state.selected_row
4561                                >= self.s3_state.bucket_scroll_offset + visible
4562                            {
4563                                self.s3_state.bucket_scroll_offset =
4564                                    self.s3_state.selected_row.saturating_sub(visible - 1);
4565                            }
4566                        }
4567                    }
4568                }
4569                return;
4570            }
4571            row_idx += 1;
4572            // Account for this bucket's loaded children
4573            if self.s3_state.expanded_prefixes.contains(&b.name) {
4574                if let Some(p) = self.s3_state.bucket_preview.get(&b.name) {
4575                    row_idx += p.len();
4576                }
4577            }
4578        }
4579    }
4580
4581    pub async fn load_prefix_preview(
4582        &mut self,
4583        bucket_name: String,
4584        prefix: String,
4585    ) -> anyhow::Result<()> {
4586        let stored_region = self
4587            .s3_state
4588            .buckets
4589            .items
4590            .iter()
4591            .find(|b| b.name == bucket_name)
4592            .and_then(|b| {
4593                if b.region.is_empty() {
4594                    None
4595                } else {
4596                    Some(b.region.clone())
4597                }
4598            });
4599
4600        let bucket_region = match stored_region {
4601            Some(r) => r,
4602            None => {
4603                let r = self.s3_client.get_bucket_location(&bucket_name).await?;
4604                if let Some(b) = self
4605                    .s3_state
4606                    .buckets
4607                    .items
4608                    .iter_mut()
4609                    .find(|b| b.name == bucket_name)
4610                {
4611                    b.region = r.clone();
4612                }
4613                r
4614            }
4615        };
4616        let objects = self
4617            .s3_client
4618            .list_objects(&bucket_name, &bucket_region, &prefix)
4619            .await?;
4620        let preview: Vec<S3Object> = objects
4621            .into_iter()
4622            .map(|(key, size, modified, is_prefix, storage_class)| S3Object {
4623                key,
4624                size,
4625                last_modified: modified,
4626                is_prefix,
4627                storage_class,
4628            })
4629            .collect();
4630        self.s3_state.prefix_preview.insert(prefix, preview);
4631        Ok(())
4632    }
4633
4634    pub async fn load_ecr_repositories(&mut self) -> anyhow::Result<()> {
4635        let repos = match self.ecr_state.tab {
4636            EcrTab::Private => self.ecr_client.list_private_repositories().await?,
4637            EcrTab::Public => self.ecr_client.list_public_repositories().await?,
4638        };
4639
4640        self.ecr_state.repositories.items = repos
4641            .into_iter()
4642            .map(|r| EcrRepository {
4643                name: r.name,
4644                uri: r.uri,
4645                created_at: r.created_at,
4646                tag_immutability: r.tag_immutability,
4647                encryption_type: r.encryption_type,
4648            })
4649            .collect();
4650
4651        self.ecr_state
4652            .repositories
4653            .items
4654            .sort_by(|a, b| a.name.cmp(&b.name));
4655        Ok(())
4656    }
4657
4658    pub async fn load_kms_keys(&mut self) -> anyhow::Result<()> {
4659        let keys = self.kms_client.list_keys().await?;
4660        self.kms_state.keys.items = keys
4661            .into_iter()
4662            .map(|k| KmsKey {
4663                key_id: k.key_id,
4664                key_arn: k.key_arn,
4665                alias: k.alias,
4666                description: k.description,
4667                key_state: k.key_state,
4668                key_usage: k.key_usage,
4669                key_spec: k.key_spec,
4670                key_manager: k.key_manager,
4671                creation_date: k.creation_date,
4672                expiration_date: k.expiration_date,
4673                deletion_date: k.deletion_date,
4674                custom_key_store_id: k.custom_key_store_id,
4675                origin: k.origin,
4676                multi_region: k.multi_region,
4677                enabled: k.enabled,
4678            })
4679            .collect();
4680        Ok(())
4681    }
4682
4683    pub async fn load_efs_file_systems(&mut self) -> anyhow::Result<()> {
4684        let file_systems = self.efs_client.list_file_systems().await?;
4685        self.efs_state.file_systems.items = file_systems
4686            .into_iter()
4687            .map(|f| EfsFileSystem {
4688                file_system_id: f.file_system_id,
4689                file_system_arn: f.file_system_arn,
4690                name: f.name,
4691                creation_token: f.creation_token,
4692                encrypted: f.encrypted,
4693                kms_key_id: f.kms_key_id,
4694                total_size: f.total_size,
4695                size_in_standard: f.size_in_standard,
4696                size_in_ia: f.size_in_ia,
4697                size_in_archive: f.size_in_archive,
4698                provisioned_throughput: f.provisioned_throughput,
4699                throughput_mode: f.throughput_mode,
4700                life_cycle_state: f.life_cycle_state,
4701                number_of_mount_targets: f.number_of_mount_targets,
4702                owner_id: f.owner_id,
4703                creation_time: f.creation_time,
4704                performance_mode: f.performance_mode,
4705                availability_zone: f.availability_zone,
4706                replication_overwrite_protection: f.replication_overwrite_protection,
4707                dns_name: f.dns_name,
4708                tags: f.tags,
4709                total_size_bytes: f.total_size_bytes,
4710                size_in_standard_bytes: f.size_in_standard_bytes,
4711                size_in_ia_bytes: f.size_in_ia_bytes,
4712                size_in_archive_bytes: f.size_in_archive_bytes,
4713            })
4714            .collect();
4715        Ok(())
4716    }
4717
4718    pub async fn load_dynamodb_tables(&mut self) -> anyhow::Result<()> {
4719        let tables = self.dynamodb_client.list_tables().await?;
4720        self.dynamodb_state.tables.items = tables;
4721        Ok(())
4722    }
4723
4724    pub async fn load_dynamodb_backups(&mut self, table_name: &str) -> anyhow::Result<()> {
4725        let backups = self.dynamodb_client.list_backups(table_name).await?;
4726        self.dynamodb_state.backups.items = backups;
4727        self.dynamodb_state.backups.selected = 0;
4728        self.dynamodb_state.backups.expanded_item = None;
4729        Ok(())
4730    }
4731
4732    /// Run exactly one bounded Items request. Only explicit LoadMore appends.
4733    pub async fn load_dynamodb_items(&mut self) -> anyhow::Result<()> {
4734        let Some(request) = ddb_items::context(self) else {
4735            self.dynamodb_state.clear_items_result();
4736            return Ok(());
4737        };
4738        let cursor = self.dynamodb_state.begin_items_request(request.clone());
4739        let projection = match request.projection {
4740            ItemProjection::AllAttributes => None,
4741            ItemProjection::KeysOnly => Some(request.keys.as_slice()),
4742        };
4743        let result = match request.operation {
4744            ItemOperation::Scan => {
4745                self.dynamodb_client
4746                    .scan_items(
4747                        &request.table,
4748                        request.index.as_deref(),
4749                        ITEM_LIMIT,
4750                        projection,
4751                        cursor,
4752                    )
4753                    .await
4754            }
4755            ItemOperation::Query => {
4756                let value = request.query.as_deref().unwrap_or_default();
4757                if value.is_empty() {
4758                    Ok(DdbItemsPage::default())
4759                } else if let Some(pk) = request.keys.first() {
4760                    self.dynamodb_client
4761                        .query_items(
4762                            &request.table,
4763                            request.index.as_deref(),
4764                            pk,
4765                            value,
4766                            &request.partition_type,
4767                            ITEM_LIMIT,
4768                            projection,
4769                            cursor,
4770                        )
4771                        .await
4772                } else {
4773                    Err(anyhow::anyhow!(
4774                        "The selected table or index has no partition key"
4775                    ))
4776                }
4777            }
4778        };
4779        match result {
4780            Ok(page) => {
4781                self.dynamodb_state.finish_items_request(page);
4782                Ok(())
4783            }
4784            Err(error) => {
4785                self.dynamodb_state.fail_items_request();
4786                Err(error)
4787            }
4788        }
4789    }
4790
4791    pub async fn load_dynamodb_tags(&mut self, table_arn: &str) -> anyhow::Result<()> {
4792        let tags = self.dynamodb_client.list_tags(table_arn).await?;
4793        self.dynamodb_state.tags_table.items = tags;
4794        self.dynamodb_state.tags_table.selected = 0;
4795        self.dynamodb_state.tags_table.expanded_item = None;
4796        Ok(())
4797    }
4798
4799    /// CloudWatch metrics for the Monitor tab.
4800    pub async fn load_dynamodb_metrics(&mut self, table_name: &str) -> anyhow::Result<()> {
4801        self.dynamodb_state.metrics = self
4802            .dynamodb_client
4803            .get_monitoring_metrics(table_name)
4804            .await?;
4805        Ok(())
4806    }
4807
4808    /// Resource-based policies for the table and its active stream.
4809    pub async fn load_dynamodb_permissions(
4810        &mut self,
4811        table_arn: &str,
4812        stream_arn: &str,
4813    ) -> anyhow::Result<()> {
4814        self.dynamodb_state.table_policy =
4815            self.dynamodb_client.get_resource_policy(table_arn).await?;
4816        self.dynamodb_state.stream_policy = if stream_arn.is_empty() {
4817            None
4818        } else {
4819            self.dynamodb_client.get_resource_policy(stream_arn).await?
4820        };
4821        Ok(())
4822    }
4823
4824    pub async fn load_dynamodb_settings(&mut self, table_name: &str) -> anyhow::Result<()> {
4825        self.dynamodb_state.settings = self.dynamodb_client.describe_settings(table_name).await?;
4826        Ok(())
4827    }
4828
4829    pub async fn load_vpcs(&mut self) -> anyhow::Result<()> {
4830        self.vpc_state.vpcs.loading = true;
4831        let result = self.vpc_client.list_vpcs_with_properties().await;
4832        self.vpc_state.vpcs.loading = false;
4833        // A failed later page must not discard the previous successful inventory.
4834        self.vpc_state.vpcs.items = result?;
4835        self.vpc_state.vpcs.reset();
4836        Ok(())
4837    }
4838
4839    pub async fn load_rds_instances(&mut self) -> anyhow::Result<()> {
4840        self.rds_state.instances.loading = true;
4841        let options =
4842            crate::rds::instance::Column::inventory_options(&self.rds_state.visible_column_ids);
4843        let result = self.rds_client.list_instances_with_options(options).await;
4844        self.rds_state.instances.loading = false;
4845        // Replace atomically: a failed page must not discard the prior inventory.
4846        let instances = result?;
4847        self.rds_state.instances.items = instances;
4848        self.rds_state.instances.reset();
4849        Ok(())
4850    }
4851
4852    pub async fn load_fsx_file_systems(&mut self) -> anyhow::Result<()> {
4853        let file_systems = self.fsx_client.list_file_systems().await?;
4854        self.fsx_state.file_systems.items = file_systems;
4855        Ok(())
4856    }
4857
4858    pub async fn load_fsx_backups(&mut self, file_system_id: &str) -> anyhow::Result<()> {
4859        let backups = self.fsx_client.describe_backups(file_system_id).await?;
4860        self.fsx_state.backups.items = backups;
4861        self.fsx_state.backups.selected = 0;
4862        self.fsx_state.backups.expanded_item = None;
4863        Ok(())
4864    }
4865
4866    pub async fn load_apis(&mut self) -> anyhow::Result<()> {
4867        let apis = self.apig_client.list_rest_apis().await?;
4868
4869        self.apig_state.apis.items = apis
4870            .into_iter()
4871            .map(|a| crate::apig::api::RestApi {
4872                id: a.id,
4873                name: a.name,
4874                description: a.description,
4875                created_date: a.created_date,
4876                api_key_source: a.api_key_source,
4877                endpoint_configuration: a.endpoint_configuration,
4878                protocol_type: a.protocol_type,
4879                disable_execute_api_endpoint: a.disable_execute_api_endpoint,
4880                status: a.status,
4881            })
4882            .collect();
4883
4884        self.apig_state
4885            .apis
4886            .items
4887            .sort_by(|a, b| a.name.cmp(&b.name));
4888        Ok(())
4889    }
4890
4891    pub async fn load_ec2_instances(&mut self) -> anyhow::Result<()> {
4892        let instances = self.ec2_client.list_instances().await?;
4893
4894        self.ec2_state.table.items = instances
4895            .into_iter()
4896            .map(|i| Ec2Instance {
4897                instance_id: i.instance_id,
4898                name: i.name,
4899                state: i.state,
4900                instance_type: i.instance_type,
4901                availability_zone: i.availability_zone,
4902                public_ipv4_dns: i.public_ipv4_dns,
4903                public_ipv4_address: i.public_ipv4_address,
4904                elastic_ip: i.elastic_ip,
4905                ipv6_ips: i.ipv6_ips,
4906                monitoring: i.monitoring,
4907                security_groups: i.security_groups,
4908                key_name: i.key_name,
4909                launch_time: i.launch_time,
4910                platform_details: i.platform_details,
4911                status_checks: i.status_checks,
4912                alarm_status: i.alarm_status,
4913                private_dns_name: i.private_dns_name,
4914                private_ip_address: i.private_ip_address,
4915                security_group_ids: String::new(),
4916                owner_id: String::new(),
4917                volume_id: String::new(),
4918                root_device_name: String::new(),
4919                root_device_type: String::new(),
4920                ebs_optimized: String::new(),
4921                image_id: String::new(),
4922                kernel_id: String::new(),
4923                ramdisk_id: String::new(),
4924                ami_launch_index: String::new(),
4925                reservation_id: String::new(),
4926                vpc_id: String::new(),
4927                subnet_ids: String::new(),
4928                instance_lifecycle: String::new(),
4929                architecture: String::new(),
4930                virtualization_type: String::new(),
4931                platform: String::new(),
4932                iam_instance_profile_arn: String::new(),
4933                tenancy: String::new(),
4934                affinity: String::new(),
4935                host_id: String::new(),
4936                placement_group: String::new(),
4937                partition_number: String::new(),
4938                capacity_reservation_id: String::new(),
4939                state_transition_reason_code: String::new(),
4940                state_transition_reason_message: String::new(),
4941                stop_hibernation_behavior: String::new(),
4942                outpost_arn: String::new(),
4943                product_codes: String::new(),
4944                availability_zone_id: String::new(),
4945                imdsv2: String::new(),
4946                usage_operation: String::new(),
4947                managed: String::new(),
4948                operator: String::new(),
4949            })
4950            .collect();
4951
4952        // Sort by launch time descending by default
4953        self.ec2_state
4954            .table
4955            .items
4956            .sort_by(|a, b| b.launch_time.cmp(&a.launch_time));
4957        Ok(())
4958    }
4959
4960    pub async fn load_ecr_images(&mut self) -> anyhow::Result<()> {
4961        if let Some(repo_name) = &self.ecr_state.current_repository {
4962            if let Some(repo_uri) = &self.ecr_state.current_repository_uri {
4963                let images = self.ecr_client.list_images(repo_name, repo_uri).await?;
4964
4965                self.ecr_state.images.items = images
4966                    .into_iter()
4967                    .map(|i| EcrImage {
4968                        tag: i.tag,
4969                        artifact_type: i.artifact_type,
4970                        pushed_at: i.pushed_at,
4971                        size_bytes: i.size_bytes,
4972                        uri: i.uri,
4973                        digest: i.digest,
4974                        last_pull_time: i.last_pull_time,
4975                    })
4976                    .collect();
4977
4978                self.ecr_state
4979                    .images
4980                    .items
4981                    .sort_by(|a, b| b.pushed_at.cmp(&a.pushed_at));
4982            }
4983        }
4984        Ok(())
4985    }
4986
4987    pub async fn load_cloudformation_stacks(&mut self) -> anyhow::Result<()> {
4988        let stacks = self
4989            .cloudformation_client
4990            .list_stacks(self.cfn_state.view_nested)
4991            .await?;
4992
4993        let mut stacks: Vec<CfnStack> = stacks
4994            .into_iter()
4995            .map(|s| CfnStack {
4996                name: s.name,
4997                stack_id: s.stack_id,
4998                status: s.status,
4999                created_time: s.created_time,
5000                updated_time: s.updated_time,
5001                deleted_time: s.deleted_time,
5002                drift_status: s.drift_status,
5003                last_drift_check_time: s.last_drift_check_time,
5004                status_reason: s.status_reason,
5005                description: s.description,
5006                detailed_status: String::new(),
5007                root_stack: s.root_stack,
5008                parent_stack: s.parent_stack,
5009                termination_protection: false,
5010                iam_role: String::new(),
5011                tags: Vec::new(),
5012                stack_policy: String::new(),
5013                rollback_monitoring_time: String::new(),
5014                rollback_alarms: Vec::new(),
5015                notification_arns: Vec::new(),
5016            })
5017            .collect();
5018
5019        // Sort by created_time DESC
5020        stacks.sort_by(|a, b| b.created_time.cmp(&a.created_time));
5021
5022        self.cfn_state.table.items = stacks;
5023
5024        Ok(())
5025    }
5026
5027    pub async fn load_cfn_template(&mut self, stack_name: &str) -> anyhow::Result<()> {
5028        let template = self.cloudformation_client.get_template(stack_name).await?;
5029        self.cfn_state.template_body = template;
5030        self.cfn_state.template_scroll = 0;
5031        Ok(())
5032    }
5033
5034    pub async fn load_cfn_parameters(&mut self, stack_name: &str) -> anyhow::Result<()> {
5035        let mut parameters = self
5036            .cloudformation_client
5037            .get_stack_parameters(stack_name)
5038            .await?;
5039        parameters.sort_by(|a, b| a.key.cmp(&b.key));
5040        self.cfn_state.parameters.items = parameters;
5041        self.cfn_state.parameters.reset();
5042        Ok(())
5043    }
5044
5045    pub async fn load_cfn_outputs(&mut self, stack_name: &str) -> anyhow::Result<()> {
5046        let outputs = self
5047            .cloudformation_client
5048            .get_stack_outputs(stack_name)
5049            .await?;
5050        self.cfn_state.outputs.items = outputs;
5051        self.cfn_state.outputs.reset();
5052        Ok(())
5053    }
5054
5055    pub async fn load_cfn_resources(&mut self, stack_name: &str) -> anyhow::Result<()> {
5056        let resources = self
5057            .cloudformation_client
5058            .get_stack_resources(stack_name)
5059            .await?;
5060        self.cfn_state.resources.items = resources;
5061        self.cfn_state.resources.reset();
5062        Ok(())
5063    }
5064
5065    pub async fn load_cfn_events(&mut self, stack_name: &str) -> anyhow::Result<()> {
5066        let events = self
5067            .cloudformation_client
5068            .list_stack_events(stack_name)
5069            .await?;
5070        self.cfn_state.events.items = events;
5071        self.cfn_state.events.reset();
5072        Ok(())
5073    }
5074
5075    pub async fn load_cfn_change_sets(&mut self, stack_name: &str) -> anyhow::Result<()> {
5076        let change_sets = self
5077            .cloudformation_client
5078            .list_change_sets(stack_name)
5079            .await?;
5080        self.cfn_state.change_sets.items = change_sets;
5081        self.cfn_state.change_sets.reset();
5082        Ok(())
5083    }
5084
5085    pub async fn load_role_policies(&mut self, role_name: &str) -> anyhow::Result<()> {
5086        // Load attached (managed) policies
5087        let attached_policies = self
5088            .iam_client
5089            .list_attached_role_policies(role_name)
5090            .await
5091            .map_err(|e| anyhow::anyhow!(e))?;
5092
5093        let mut policies: Vec<IamPolicy> = Vec::new();
5094        for p in attached_policies {
5095            let (creation_time, edited_time) = if let Some(arn) = p.policy_arn() {
5096                self.iam_client
5097                    .get_policy_metadata(arn)
5098                    .await
5099                    .unwrap_or_else(|_| ("-".to_string(), "-".to_string()))
5100            } else {
5101                ("-".to_string(), "-".to_string())
5102            };
5103            policies.push(IamPolicy {
5104                policy_name: p.policy_name().unwrap_or("").to_string(),
5105                policy_type: "Managed".to_string(),
5106                attached_via: "Direct".to_string(),
5107                attached_entities: "-".to_string(),
5108                description: "-".to_string(),
5109                creation_time,
5110                edited_time,
5111                policy_arn: p.policy_arn().map(|s| s.to_string()),
5112            });
5113        }
5114
5115        // Load inline policies
5116        let inline_policy_names = self
5117            .iam_client
5118            .list_role_policies(role_name)
5119            .await
5120            .map_err(|e| anyhow::anyhow!(e))?;
5121
5122        for policy_name in inline_policy_names {
5123            policies.push(IamPolicy {
5124                policy_name,
5125                policy_type: "Inline".to_string(),
5126                attached_via: "Direct".to_string(),
5127                attached_entities: "-".to_string(),
5128                description: "-".to_string(),
5129                creation_time: "-".to_string(),
5130                edited_time: "-".to_string(),
5131                policy_arn: None,
5132            });
5133        }
5134
5135        self.iam_state.policies.items = policies;
5136
5137        Ok(())
5138    }
5139
5140    pub async fn load_group_policies(&mut self, group_name: &str) -> anyhow::Result<()> {
5141        let attached_policies = self
5142            .iam_client
5143            .list_attached_group_policies(group_name)
5144            .await
5145            .map_err(|e| anyhow::anyhow!(e))?;
5146
5147        let mut policies: Vec<IamPolicy> = attached_policies
5148            .into_iter()
5149            .map(|p| IamPolicy {
5150                policy_name: p.policy_name().unwrap_or("").to_string(),
5151                policy_type: "AWS managed".to_string(),
5152                attached_via: "Direct".to_string(),
5153                attached_entities: "-".to_string(),
5154                description: "-".to_string(),
5155                creation_time: "-".to_string(),
5156                edited_time: "-".to_string(),
5157                policy_arn: p.policy_arn().map(|s| s.to_string()),
5158            })
5159            .collect();
5160
5161        let inline_policy_names = self
5162            .iam_client
5163            .list_group_policies(group_name)
5164            .await
5165            .map_err(|e| anyhow::anyhow!(e))?;
5166
5167        for policy_name in inline_policy_names {
5168            policies.push(IamPolicy {
5169                policy_name,
5170                policy_type: "Inline".to_string(),
5171                attached_via: "Direct".to_string(),
5172                attached_entities: "-".to_string(),
5173                description: "-".to_string(),
5174                creation_time: "-".to_string(),
5175                edited_time: "-".to_string(),
5176                policy_arn: None,
5177            });
5178        }
5179
5180        self.iam_state.policies.items = policies;
5181
5182        Ok(())
5183    }
5184
5185    pub async fn load_group_users(&mut self, group_name: &str) -> anyhow::Result<()> {
5186        let users = self
5187            .iam_client
5188            .get_group_users(group_name)
5189            .await
5190            .map_err(|e| anyhow::anyhow!(e))?;
5191
5192        let group_users: Vec<IamGroupUser> = users
5193            .into_iter()
5194            .map(|u| {
5195                let creation_time = {
5196                    let dt = u.create_date();
5197                    let timestamp = dt.secs();
5198                    let datetime =
5199                        chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
5200                    datetime.format("%Y-%m-%d %H:%M:%S (UTC)").to_string()
5201                };
5202
5203                IamGroupUser {
5204                    user_name: u.user_name().to_string(),
5205                    groups: String::new(),
5206                    last_activity: String::new(),
5207                    creation_time,
5208                }
5209            })
5210            .collect();
5211
5212        self.iam_state.group_users.items = group_users;
5213
5214        Ok(())
5215    }
5216
5217    pub async fn load_policy_document(
5218        &mut self,
5219        role_name: &str,
5220        policy_name: &str,
5221    ) -> anyhow::Result<()> {
5222        // Find the policy to get its ARN and type
5223        let policy = self
5224            .iam_state
5225            .policies
5226            .items
5227            .iter()
5228            .find(|p| p.policy_name == policy_name)
5229            .ok_or_else(|| anyhow::anyhow!("Policy not found"))?;
5230
5231        let document = if let Some(policy_arn) = &policy.policy_arn {
5232            // Managed policy - use get_policy_version
5233            self.iam_client
5234                .get_policy_version(policy_arn)
5235                .await
5236                .map_err(|e| anyhow::anyhow!(e))?
5237        } else {
5238            // Inline policy - use get_role_policy
5239            self.iam_client
5240                .get_role_policy(role_name, policy_name)
5241                .await
5242                .map_err(|e| anyhow::anyhow!(e))?
5243        };
5244
5245        self.iam_state.policy_document = document;
5246
5247        Ok(())
5248    }
5249
5250    pub async fn load_trust_policy(&mut self, role_name: &str) -> anyhow::Result<()> {
5251        let document = self
5252            .iam_client
5253            .get_role(role_name)
5254            .await
5255            .map_err(|e| anyhow::anyhow!(e))?;
5256
5257        self.iam_state.trust_policy_document = document;
5258
5259        Ok(())
5260    }
5261
5262    pub async fn load_last_accessed_services(&mut self, _role_name: &str) -> anyhow::Result<()> {
5263        // TODO: Implement real AWS API call to get service last accessed details
5264        self.iam_state.last_accessed_services.items = vec![];
5265        self.iam_state.last_accessed_services.selected = 0;
5266
5267        Ok(())
5268    }
5269
5270    pub async fn load_role_tags(&mut self, role_name: &str) -> anyhow::Result<()> {
5271        let tags = self
5272            .iam_client
5273            .list_role_tags(role_name)
5274            .await
5275            .map_err(|e| anyhow::anyhow!(e))?;
5276        self.iam_state.tags.items = tags
5277            .into_iter()
5278            .map(|(k, v)| IamRoleTag { key: k, value: v })
5279            .collect();
5280        self.iam_state.tags.reset();
5281        Ok(())
5282    }
5283
5284    pub async fn load_user_tags(&mut self, user_name: &str) -> anyhow::Result<()> {
5285        let tags = self
5286            .iam_client
5287            .list_user_tags(user_name)
5288            .await
5289            .map_err(|e| anyhow::anyhow!(e))?;
5290        self.iam_state.user_tags.items = tags
5291            .into_iter()
5292            .map(|(k, v)| IamUserTag { key: k, value: v })
5293            .collect();
5294        self.iam_state.user_tags.reset();
5295        Ok(())
5296    }
5297
5298    pub async fn load_log_streams(&mut self) -> anyhow::Result<()> {
5299        if let Some(group) = self
5300            .log_groups_state
5301            .log_groups
5302            .items
5303            .get(self.log_groups_state.log_groups.selected)
5304        {
5305            self.log_groups_state.log_streams =
5306                self.cloudwatch_client.list_log_streams(&group.name).await?;
5307            self.log_groups_state.selected_stream = 0;
5308        }
5309        Ok(())
5310    }
5311
5312    pub async fn load_log_group_tags(&mut self) -> anyhow::Result<()> {
5313        if let Some(group) = self
5314            .log_groups_state
5315            .log_groups
5316            .items
5317            .get(self.log_groups_state.log_groups.selected)
5318        {
5319            // Use log_group_arn if available, otherwise construct from name
5320            let arn = if let Some(arn) = &group.log_group_arn {
5321                arn.clone()
5322            } else if let Some(arn) = &group.arn {
5323                arn.clone()
5324            } else {
5325                // Construct ARN from log group name
5326                let account_id = if self.config.account_id.is_empty() {
5327                    "*"
5328                } else {
5329                    &self.config.account_id
5330                };
5331                format!(
5332                    "arn:aws:logs:{}:{}:log-group:{}",
5333                    self.config.region, account_id, group.name
5334                )
5335            };
5336
5337            let tags = self.cloudwatch_client.list_tags_for_log_group(&arn).await?;
5338            self.log_groups_state.tags.items = tags;
5339            self.log_groups_state.tags.selected = 0;
5340            self.log_groups_state.tags.scroll_offset = 0;
5341        }
5342        Ok(())
5343    }
5344
5345    pub async fn load_log_events(&mut self) -> anyhow::Result<()> {
5346        if let Some(group) = self
5347            .log_groups_state
5348            .log_groups
5349            .items
5350            .get(self.log_groups_state.log_groups.selected)
5351        {
5352            if let Some(stream) = self
5353                .log_groups_state
5354                .log_streams
5355                .get(self.log_groups_state.selected_stream)
5356            {
5357                // Calculate time range from relative date picker
5358                let (start_time, end_time) =
5359                    if let Ok(amount) = self.log_groups_state.relative_amount.parse::<i64>() {
5360                        let now = chrono::Utc::now().timestamp_millis();
5361                        let duration_ms = match self.log_groups_state.relative_unit {
5362                            TimeUnit::Minutes => amount * 60 * 1000,
5363                            TimeUnit::Hours => amount * 60 * 60 * 1000,
5364                            TimeUnit::Days => amount * 24 * 60 * 60 * 1000,
5365                            TimeUnit::Weeks => amount * 7 * 24 * 60 * 60 * 1000,
5366                        };
5367                        (Some(now - duration_ms), Some(now))
5368                    } else {
5369                        (None, None)
5370                    };
5371
5372                let (mut events, has_more, token) = self
5373                    .cloudwatch_client
5374                    .get_log_events(
5375                        &group.name,
5376                        &stream.name,
5377                        self.log_groups_state.next_backward_token.clone(),
5378                        start_time,
5379                        end_time,
5380                    )
5381                    .await?;
5382
5383                if self.log_groups_state.next_backward_token.is_some() {
5384                    // Prepend older events - keep selection at top
5385                    events.append(&mut self.log_groups_state.log_events);
5386                    self.log_groups_state.event_scroll_offset = 0;
5387                } else {
5388                    // Initial load - start at first event
5389                    self.log_groups_state.event_scroll_offset = 0;
5390                }
5391
5392                self.log_groups_state.log_events = events;
5393                self.log_groups_state.has_older_events =
5394                    has_more && self.log_groups_state.log_events.len() >= 25;
5395                self.log_groups_state.next_backward_token = token;
5396                self.log_groups_state.selected_event = 0;
5397            }
5398        }
5399        Ok(())
5400    }
5401
5402    pub async fn execute_insights_query(&mut self) -> anyhow::Result<()> {
5403        if self.insights_state.insights.selected_log_groups.is_empty() {
5404            return Err(anyhow::anyhow!(
5405                "No log groups selected. Please select at least one log group."
5406            ));
5407        }
5408
5409        let now = chrono::Utc::now().timestamp_millis();
5410        let amount = self
5411            .insights_state
5412            .insights
5413            .insights_relative_amount
5414            .parse::<i64>()
5415            .unwrap_or(1);
5416        let duration_ms = match self.insights_state.insights.insights_relative_unit {
5417            TimeUnit::Minutes => amount * 60 * 1000,
5418            TimeUnit::Hours => amount * 60 * 60 * 1000,
5419            TimeUnit::Days => amount * 24 * 60 * 60 * 1000,
5420            TimeUnit::Weeks => amount * 7 * 24 * 60 * 60 * 1000,
5421        };
5422        let start_time = now - duration_ms;
5423
5424        let query_id = self
5425            .cloudwatch_client
5426            .start_query(
5427                self.insights_state.insights.selected_log_groups.clone(),
5428                self.insights_state.insights.query_text.trim().to_string(),
5429                start_time,
5430                now,
5431            )
5432            .await?;
5433
5434        // Poll for results
5435        for _ in 0..60 {
5436            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
5437            let (status, results) = self.cloudwatch_client.get_query_results(&query_id).await?;
5438
5439            if status == "Complete" {
5440                self.insights_state.insights.query_results = results;
5441                self.insights_state.insights.query_completed = true;
5442                self.insights_state.insights.results_selected = 0;
5443                self.insights_state.insights.expanded_result = None;
5444                self.view_mode = ViewMode::InsightsResults;
5445                return Ok(());
5446            } else if status == "Failed" || status == "Cancelled" {
5447                return Err(anyhow::anyhow!("Query {}", status.to_lowercase()));
5448            }
5449        }
5450
5451        Err(anyhow::anyhow!("Query timeout"))
5452    }
5453}
5454
5455impl CloudWatchInsightsState {
5456    fn new() -> Self {
5457        Self {
5458            insights: InsightsState::default(),
5459            loading: false,
5460        }
5461    }
5462}
5463
5464impl CloudWatchAlarmsState {
5465    fn new() -> Self {
5466        Self {
5467            table: TableState::new(),
5468            current_alarm: None,
5469            alarm_tab: AlarmTab::AllAlarms,
5470            detail_tab: AlarmDetailTab::Details,
5471            view_as: AlarmViewMode::Table,
5472            wrap_lines: false,
5473            sort_column: "Last state update".to_string(),
5474            sort_direction: SortDirection::Asc,
5475            input_focus: InputFocus::Filter,
5476            metric_data: Vec::new(),
5477            metrics_loading: false,
5478        }
5479    }
5480}
5481
5482impl ServicePickerState {
5483    fn new() -> Self {
5484        Self {
5485            filter: String::new(),
5486            filter_active: false,
5487            selected: 0,
5488            services: vec![
5489                "API Gateway › APIs",
5490                "CloudWatch › Log Groups",
5491                "CloudWatch › Logs Insights",
5492                "CloudWatch › Alarms",
5493                "CloudTrail › Event History",
5494                "CloudFormation › Stacks",
5495                "EC2 › Instances",
5496                "ECR › Repositories",
5497                "IAM › Users",
5498                "IAM › Roles",
5499                "IAM › User Groups",
5500                "KMS › Managed Keys",
5501                "EFS › File Systems",
5502                "FSx › File Systems",
5503                "DynamoDB › Tables",
5504                "Lambda › Functions",
5505                "Lambda › Applications",
5506                "RDS › Databases",
5507                "S3 › Buckets",
5508                "SQS › Queues",
5509                "VPC › VPCs",
5510            ],
5511        }
5512    }
5513}
5514
5515#[cfg(test)]
5516mod test_helpers {
5517    use super::*;
5518
5519    // Test helper functions to reduce boilerplate
5520    pub fn test_app() -> App {
5521        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
5522    }
5523
5524    pub fn test_app_no_region() -> App {
5525        App::new_without_client("test".to_string(), None)
5526    }
5527}
5528
5529#[cfg(test)]
5530mod tests {
5531    use super::*;
5532    use crate::keymap::Action;
5533    use test_helpers::*;
5534
5535    #[test]
5536    fn test_next_tab_cycles_forward() {
5537        let mut app = test_app();
5538        app.tabs = vec![
5539            Tab {
5540                service: Service::CloudWatchLogGroups,
5541                title: "CloudWatch › Log Groups".to_string(),
5542                breadcrumb: "CloudWatch › Log Groups".to_string(),
5543            },
5544            Tab {
5545                service: Service::CloudWatchInsights,
5546                title: "CloudWatch › Logs Insights".to_string(),
5547                breadcrumb: "CloudWatch › Logs Insights".to_string(),
5548            },
5549            Tab {
5550                service: Service::CloudWatchAlarms,
5551                title: "CloudWatch › Alarms".to_string(),
5552                breadcrumb: "CloudWatch › Alarms".to_string(),
5553            },
5554        ];
5555        app.current_tab = 0;
5556
5557        app.handle_action(Action::NextTab);
5558        assert_eq!(app.current_tab, 1);
5559        assert_eq!(app.current_service, Service::CloudWatchInsights);
5560
5561        app.handle_action(Action::NextTab);
5562        assert_eq!(app.current_tab, 2);
5563        assert_eq!(app.current_service, Service::CloudWatchAlarms);
5564
5565        // Should wrap around
5566        app.handle_action(Action::NextTab);
5567        assert_eq!(app.current_tab, 0);
5568        assert_eq!(app.current_service, Service::CloudWatchLogGroups);
5569    }
5570
5571    #[test]
5572    fn test_prev_tab_cycles_backward() {
5573        let mut app = test_app();
5574        app.tabs = vec![
5575            Tab {
5576                service: Service::CloudWatchLogGroups,
5577                title: "CloudWatch › Log Groups".to_string(),
5578                breadcrumb: "CloudWatch › Log Groups".to_string(),
5579            },
5580            Tab {
5581                service: Service::CloudWatchInsights,
5582                title: "CloudWatch › Logs Insights".to_string(),
5583                breadcrumb: "CloudWatch › Logs Insights".to_string(),
5584            },
5585            Tab {
5586                service: Service::CloudWatchAlarms,
5587                title: "CloudWatch › Alarms".to_string(),
5588                breadcrumb: "CloudWatch › Alarms".to_string(),
5589            },
5590        ];
5591        app.current_tab = 2;
5592
5593        app.handle_action(Action::PrevTab);
5594        assert_eq!(app.current_tab, 1);
5595        assert_eq!(app.current_service, Service::CloudWatchInsights);
5596
5597        app.handle_action(Action::PrevTab);
5598        assert_eq!(app.current_tab, 0);
5599        assert_eq!(app.current_service, Service::CloudWatchLogGroups);
5600
5601        // Should wrap around
5602        app.handle_action(Action::PrevTab);
5603        assert_eq!(app.current_tab, 2);
5604        assert_eq!(app.current_service, Service::CloudWatchAlarms);
5605    }
5606
5607    #[test]
5608    fn test_close_tab_removes_current() {
5609        let mut app = test_app();
5610        app.tabs = vec![
5611            Tab {
5612                service: Service::CloudWatchLogGroups,
5613                title: "CloudWatch › Log Groups".to_string(),
5614                breadcrumb: "CloudWatch › Log Groups".to_string(),
5615            },
5616            Tab {
5617                service: Service::CloudWatchInsights,
5618                title: "CloudWatch › Logs Insights".to_string(),
5619                breadcrumb: "CloudWatch › Logs Insights".to_string(),
5620            },
5621            Tab {
5622                service: Service::CloudWatchAlarms,
5623                title: "CloudWatch › Alarms".to_string(),
5624                breadcrumb: "CloudWatch › Alarms".to_string(),
5625            },
5626        ];
5627        app.current_tab = 1;
5628        app.service_selected = true;
5629
5630        app.handle_action(Action::CloseTab);
5631        assert_eq!(app.tabs.len(), 2);
5632        assert_eq!(app.current_tab, 1);
5633        assert_eq!(app.current_service, Service::CloudWatchAlarms);
5634    }
5635
5636    #[test]
5637    fn test_close_last_tab_exits_service() {
5638        let mut app = test_app();
5639        app.tabs = vec![Tab {
5640            service: Service::CloudWatchLogGroups,
5641            title: "CloudWatch › Log Groups".to_string(),
5642            breadcrumb: "CloudWatch › Log Groups".to_string(),
5643        }];
5644        app.current_tab = 0;
5645        app.service_selected = true;
5646
5647        app.handle_action(Action::CloseTab);
5648        assert_eq!(app.tabs.len(), 0);
5649        assert!(!app.service_selected);
5650        assert_eq!(app.current_tab, 0);
5651    }
5652
5653    #[test]
5654    fn test_close_service_removes_current_tab() {
5655        let mut app = test_app();
5656        app.tabs = vec![
5657            Tab {
5658                service: Service::CloudWatchLogGroups,
5659                title: "CloudWatch › Log Groups".to_string(),
5660                breadcrumb: "CloudWatch › Log Groups".to_string(),
5661            },
5662            Tab {
5663                service: Service::CloudWatchInsights,
5664                title: "CloudWatch › Logs Insights".to_string(),
5665                breadcrumb: "CloudWatch › Logs Insights".to_string(),
5666            },
5667            Tab {
5668                service: Service::CloudWatchAlarms,
5669                title: "CloudWatch › Alarms".to_string(),
5670                breadcrumb: "CloudWatch › Alarms".to_string(),
5671            },
5672        ];
5673        app.current_tab = 1;
5674        app.service_selected = true;
5675
5676        app.handle_action(Action::CloseService);
5677
5678        // Tab should be removed
5679        assert_eq!(app.tabs.len(), 2);
5680        // Should switch to next tab (Alarms at index 1)
5681        assert_eq!(app.current_tab, 1);
5682        assert_eq!(app.current_service, Service::CloudWatchAlarms);
5683        // Should stay in service mode, NOT show service picker
5684        assert!(app.service_selected);
5685        assert_eq!(app.mode, Mode::Normal);
5686    }
5687
5688    #[test]
5689    fn test_close_service_last_tab_shows_picker() {
5690        let mut app = test_app();
5691        app.tabs = vec![Tab {
5692            service: Service::CloudWatchLogGroups,
5693            title: "CloudWatch › Log Groups".to_string(),
5694            breadcrumb: "CloudWatch › Log Groups".to_string(),
5695        }];
5696        app.current_tab = 0;
5697        app.service_selected = true;
5698
5699        app.handle_action(Action::CloseService);
5700
5701        // Tab should be removed
5702        assert_eq!(app.tabs.len(), 0);
5703        // Should show service picker
5704        assert!(!app.service_selected);
5705        assert_eq!(app.mode, Mode::ServicePicker);
5706    }
5707
5708    #[test]
5709    fn test_open_tab_picker_with_tabs() {
5710        let mut app = test_app();
5711        app.tabs = vec![
5712            Tab {
5713                service: Service::CloudWatchLogGroups,
5714                title: "CloudWatch › Log Groups".to_string(),
5715                breadcrumb: "CloudWatch › Log Groups".to_string(),
5716            },
5717            Tab {
5718                service: Service::CloudWatchInsights,
5719                title: "CloudWatch › Logs Insights".to_string(),
5720                breadcrumb: "CloudWatch › Logs Insights".to_string(),
5721            },
5722        ];
5723        app.current_tab = 1;
5724
5725        app.handle_action(Action::OpenTabPicker);
5726        assert_eq!(app.mode, Mode::TabPicker);
5727        assert_eq!(app.tab_picker_selected, 1);
5728    }
5729
5730    #[test]
5731    fn test_open_tab_picker_without_tabs() {
5732        let mut app = test_app();
5733        app.tabs = vec![];
5734
5735        app.handle_action(Action::OpenTabPicker);
5736        assert_eq!(app.mode, Mode::Normal);
5737    }
5738
5739    #[test]
5740    fn test_pending_key_state() {
5741        let mut app = test_app();
5742        assert_eq!(app.pending_key, None);
5743
5744        app.pending_key = Some('g');
5745        assert_eq!(app.pending_key, Some('g'));
5746    }
5747
5748    #[test]
5749    fn test_tab_breadcrumb_updates() {
5750        let mut app = test_app();
5751        app.tabs = vec![Tab {
5752            service: Service::CloudWatchLogGroups,
5753            title: "CloudWatch › Log Groups".to_string(),
5754            breadcrumb: "CloudWatch > Log groups".to_string(),
5755        }];
5756        app.current_tab = 0;
5757        app.service_selected = true;
5758        app.current_service = Service::CloudWatchLogGroups;
5759
5760        // Initial breadcrumb
5761        assert_eq!(app.tabs[0].breadcrumb, "CloudWatch > Log groups");
5762
5763        // Add a log group and update breadcrumb
5764        app.log_groups_state
5765            .log_groups
5766            .items
5767            .push(rusticity_core::LogGroup {
5768                name: "/aws/lambda/test".to_string(),
5769                creation_time: None,
5770                stored_bytes: Some(1024),
5771                retention_days: None,
5772                log_class: None,
5773                arn: None,
5774                log_group_arn: None,
5775                deletion_protection_enabled: None,
5776            });
5777        app.log_groups_state.log_groups.reset();
5778        app.view_mode = ViewMode::Detail;
5779        app.update_current_tab_breadcrumb();
5780
5781        // Breadcrumb should now include log group
5782        assert_eq!(
5783            app.tabs[0].breadcrumb,
5784            "CloudWatch > Log groups > /aws/lambda/test"
5785        );
5786    }
5787
5788    #[test]
5789    fn test_s3_bucket_column_selector_navigation() {
5790        let mut app = test_app();
5791        app.current_service = Service::S3Buckets;
5792        app.mode = Mode::ColumnSelector;
5793        app.column_selector_index = 0;
5794
5795        // Should navigate through 3 S3 bucket columns (0, 1, 2)
5796        app.handle_action(Action::NextItem);
5797        assert_eq!(app.column_selector_index, 1);
5798
5799        app.handle_action(Action::NextItem);
5800        assert_eq!(app.column_selector_index, 2);
5801
5802        app.handle_action(Action::NextItem);
5803        assert_eq!(app.column_selector_index, 3);
5804
5805        // Should not go beyond max (now includes page size options: 3 columns + 6 = 9)
5806        for _ in 0..10 {
5807            app.handle_action(Action::NextItem);
5808        }
5809        assert_eq!(app.column_selector_index, 9);
5810
5811        // Navigate back
5812        app.handle_action(Action::PrevItem);
5813        assert_eq!(app.column_selector_index, 8);
5814
5815        app.handle_action(Action::PrevItem);
5816        assert_eq!(app.column_selector_index, 7);
5817
5818        // Should not go below 0
5819        for _ in 0..10 {
5820            app.handle_action(Action::PrevItem);
5821        }
5822        assert_eq!(app.column_selector_index, 0);
5823    }
5824
5825    #[test]
5826    fn test_cloudwatch_alarms_state_initialized() {
5827        let app = test_app();
5828
5829        // Alarms state should be initialized
5830        assert_eq!(app.alarms_state.table.items.len(), 0);
5831        assert_eq!(app.alarms_state.table.selected, 0);
5832        assert_eq!(app.alarms_state.alarm_tab, AlarmTab::AllAlarms);
5833        assert!(!app.alarms_state.table.loading);
5834        assert_eq!(app.alarms_state.view_as, AlarmViewMode::Table);
5835        assert_eq!(app.alarms_state.table.page_size, PageSize::Fifty);
5836    }
5837
5838    #[test]
5839    fn test_cloudwatch_alarms_service_selection() {
5840        let mut app = test_app();
5841
5842        // Switch to alarms service
5843        app.current_service = Service::CloudWatchAlarms;
5844        app.service_selected = true;
5845
5846        assert_eq!(app.current_service, Service::CloudWatchAlarms);
5847        assert!(app.service_selected);
5848    }
5849
5850    #[test]
5851    fn test_cloudwatch_alarms_column_preferences() {
5852        let app = test_app();
5853
5854        // Should have alarm columns defined
5855        assert!(!app.cw_alarm_column_ids.is_empty());
5856        assert!(!app.cw_alarm_visible_column_ids.is_empty());
5857
5858        // Default visible columns
5859        assert!(app
5860            .cw_alarm_visible_column_ids
5861            .contains(&AlarmColumn::Name.id()));
5862        assert!(app
5863            .cw_alarm_visible_column_ids
5864            .contains(&AlarmColumn::State.id()));
5865    }
5866
5867    #[test]
5868    fn test_s3_bucket_navigation_without_expansion() {
5869        let mut app = test_app();
5870        app.current_service = Service::S3Buckets;
5871        app.service_selected = true;
5872        app.mode = Mode::Normal;
5873
5874        // Add 3 buckets
5875        app.s3_state.buckets.items = vec![
5876            S3Bucket {
5877                name: "bucket1".to_string(),
5878                region: "us-east-1".to_string(),
5879                creation_date: "2024-01-01T00:00:00Z".to_string(),
5880            },
5881            S3Bucket {
5882                name: "bucket2".to_string(),
5883                region: "us-east-1".to_string(),
5884                creation_date: "2024-01-02T00:00:00Z".to_string(),
5885            },
5886            S3Bucket {
5887                name: "bucket3".to_string(),
5888                region: "us-east-1".to_string(),
5889                creation_date: "2024-01-03T00:00:00Z".to_string(),
5890            },
5891        ];
5892        app.s3_state.selected_row = 0;
5893
5894        // Navigate down
5895        app.handle_action(Action::NextItem);
5896        assert_eq!(app.s3_state.selected_row, 1);
5897
5898        app.handle_action(Action::NextItem);
5899        assert_eq!(app.s3_state.selected_row, 2);
5900
5901        // Should not go beyond last bucket
5902        app.handle_action(Action::NextItem);
5903        assert_eq!(app.s3_state.selected_row, 2);
5904
5905        // Navigate up
5906        app.handle_action(Action::PrevItem);
5907        assert_eq!(app.s3_state.selected_row, 1);
5908
5909        app.handle_action(Action::PrevItem);
5910        assert_eq!(app.s3_state.selected_row, 0);
5911
5912        // Should not go below 0
5913        app.handle_action(Action::PrevItem);
5914        assert_eq!(app.s3_state.selected_row, 0);
5915    }
5916
5917    #[test]
5918    fn test_s3_bucket_navigation_with_expansion() {
5919        let mut app = test_app();
5920        app.current_service = Service::S3Buckets;
5921        app.service_selected = true;
5922        app.mode = Mode::Normal;
5923
5924        // Add 2 buckets
5925        app.s3_state.buckets.items = vec![
5926            S3Bucket {
5927                name: "bucket1".to_string(),
5928                region: "us-east-1".to_string(),
5929                creation_date: "2024-01-01T00:00:00Z".to_string(),
5930            },
5931            S3Bucket {
5932                name: "bucket2".to_string(),
5933                region: "us-east-1".to_string(),
5934                creation_date: "2024-01-02T00:00:00Z".to_string(),
5935            },
5936        ];
5937
5938        // Expand bucket1 with 2 objects
5939        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
5940        app.s3_state.bucket_preview.insert(
5941            "bucket1".to_string(),
5942            vec![
5943                S3Object {
5944                    key: "file1.txt".to_string(),
5945                    size: 100,
5946                    last_modified: "2024-01-01T00:00:00Z".to_string(),
5947                    is_prefix: false,
5948                    storage_class: "STANDARD".to_string(),
5949                },
5950                S3Object {
5951                    key: "folder/".to_string(),
5952                    size: 0,
5953                    last_modified: "2024-01-01T00:00:00Z".to_string(),
5954                    is_prefix: true,
5955                    storage_class: String::new(),
5956                },
5957            ],
5958        );
5959
5960        app.s3_state.selected_row = 0;
5961
5962        // Total rows: bucket1 (row 0) + file1.txt (row 1) + folder/ (row 2) + bucket2 (row 3) = 4 rows
5963        // Navigate through all rows
5964        app.handle_action(Action::NextItem);
5965        assert_eq!(app.s3_state.selected_row, 1); // file1.txt
5966
5967        app.handle_action(Action::NextItem);
5968        assert_eq!(app.s3_state.selected_row, 2); // folder/
5969
5970        app.handle_action(Action::NextItem);
5971        assert_eq!(app.s3_state.selected_row, 3); // bucket2
5972
5973        // Should not go beyond last row
5974        app.handle_action(Action::NextItem);
5975        assert_eq!(app.s3_state.selected_row, 3);
5976    }
5977
5978    #[test]
5979    fn test_s3_bucket_navigation_with_nested_expansion() {
5980        let mut app = test_app();
5981        app.current_service = Service::S3Buckets;
5982        app.service_selected = true;
5983        app.mode = Mode::Normal;
5984
5985        // Add 1 bucket
5986        app.s3_state.buckets.items = vec![S3Bucket {
5987            name: "bucket1".to_string(),
5988            region: "us-east-1".to_string(),
5989            creation_date: "2024-01-01T00:00:00Z".to_string(),
5990        }];
5991
5992        // Expand bucket1 with a folder
5993        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
5994        app.s3_state.bucket_preview.insert(
5995            "bucket1".to_string(),
5996            vec![S3Object {
5997                key: "folder/".to_string(),
5998                size: 0,
5999                last_modified: "2024-01-01T00:00:00Z".to_string(),
6000                is_prefix: true,
6001                storage_class: String::new(),
6002            }],
6003        );
6004
6005        // Expand the folder with 2 nested objects
6006        app.s3_state.expanded_prefixes.insert("folder/".to_string());
6007        app.s3_state.prefix_preview.insert(
6008            "folder/".to_string(),
6009            vec![
6010                S3Object {
6011                    key: "folder/file1.txt".to_string(),
6012                    size: 100,
6013                    last_modified: "2024-01-01T00:00:00Z".to_string(),
6014                    is_prefix: false,
6015                    storage_class: "STANDARD".to_string(),
6016                },
6017                S3Object {
6018                    key: "folder/file2.txt".to_string(),
6019                    size: 200,
6020                    last_modified: "2024-01-01T00:00:00Z".to_string(),
6021                    is_prefix: false,
6022                    storage_class: "STANDARD".to_string(),
6023                },
6024            ],
6025        );
6026
6027        app.s3_state.selected_row = 0;
6028
6029        // Total rows: bucket1 (0) + folder/ (1) + file1.txt (2) + file2.txt (3) = 4 rows
6030        app.handle_action(Action::NextItem);
6031        assert_eq!(app.s3_state.selected_row, 1); // folder/
6032
6033        app.handle_action(Action::NextItem);
6034        assert_eq!(app.s3_state.selected_row, 2); // folder/file1.txt
6035
6036        app.handle_action(Action::NextItem);
6037        assert_eq!(app.s3_state.selected_row, 3); // folder/file2.txt
6038
6039        // Should not go beyond last row
6040        app.handle_action(Action::NextItem);
6041        assert_eq!(app.s3_state.selected_row, 3);
6042    }
6043
6044    #[test]
6045    fn test_calculate_total_bucket_rows() {
6046        let mut app = test_app();
6047
6048        // No buckets
6049        assert_eq!(app.calculate_total_bucket_rows(), 0);
6050
6051        // 2 buckets, no expansion
6052        app.s3_state.buckets.items = vec![
6053            S3Bucket {
6054                name: "bucket1".to_string(),
6055                region: "us-east-1".to_string(),
6056                creation_date: "2024-01-01T00:00:00Z".to_string(),
6057            },
6058            S3Bucket {
6059                name: "bucket2".to_string(),
6060                region: "us-east-1".to_string(),
6061                creation_date: "2024-01-02T00:00:00Z".to_string(),
6062            },
6063        ];
6064        assert_eq!(app.calculate_total_bucket_rows(), 2);
6065
6066        // Expand bucket1 with 3 objects
6067        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
6068        app.s3_state.bucket_preview.insert(
6069            "bucket1".to_string(),
6070            vec![
6071                S3Object {
6072                    key: "file1.txt".to_string(),
6073                    size: 100,
6074                    last_modified: "2024-01-01T00:00:00Z".to_string(),
6075                    is_prefix: false,
6076                    storage_class: "STANDARD".to_string(),
6077                },
6078                S3Object {
6079                    key: "file2.txt".to_string(),
6080                    size: 200,
6081                    last_modified: "2024-01-01T00:00:00Z".to_string(),
6082                    is_prefix: false,
6083                    storage_class: "STANDARD".to_string(),
6084                },
6085                S3Object {
6086                    key: "folder/".to_string(),
6087                    size: 0,
6088                    last_modified: "2024-01-01T00:00:00Z".to_string(),
6089                    is_prefix: true,
6090                    storage_class: String::new(),
6091                },
6092            ],
6093        );
6094        assert_eq!(app.calculate_total_bucket_rows(), 5); // 2 buckets + 3 objects
6095
6096        // Expand folder/ with 2 nested objects
6097        app.s3_state.expanded_prefixes.insert("folder/".to_string());
6098        app.s3_state.prefix_preview.insert(
6099            "folder/".to_string(),
6100            vec![
6101                S3Object {
6102                    key: "folder/nested1.txt".to_string(),
6103                    size: 50,
6104                    last_modified: "2024-01-01T00:00:00Z".to_string(),
6105                    is_prefix: false,
6106                    storage_class: "STANDARD".to_string(),
6107                },
6108                S3Object {
6109                    key: "folder/nested2.txt".to_string(),
6110                    size: 75,
6111                    last_modified: "2024-01-01T00:00:00Z".to_string(),
6112                    is_prefix: false,
6113                    storage_class: "STANDARD".to_string(),
6114                },
6115            ],
6116        );
6117        assert_eq!(app.calculate_total_bucket_rows(), 7); // 2 buckets + 3 objects + 2 nested
6118    }
6119
6120    #[test]
6121    fn test_calculate_total_object_rows() {
6122        let mut app = test_app();
6123        app.s3_state.current_bucket = Some("test-bucket".to_string());
6124
6125        // No objects
6126        assert_eq!(app.calculate_total_object_rows(), 0);
6127
6128        // 2 objects, no expansion
6129        app.s3_state.objects = vec![
6130            S3Object {
6131                key: "file1.txt".to_string(),
6132                size: 100,
6133                last_modified: "2024-01-01T00:00:00Z".to_string(),
6134                is_prefix: false,
6135                storage_class: "STANDARD".to_string(),
6136            },
6137            S3Object {
6138                key: "folder/".to_string(),
6139                size: 0,
6140                last_modified: "2024-01-01T00:00:00Z".to_string(),
6141                is_prefix: true,
6142                storage_class: String::new(),
6143            },
6144        ];
6145        assert_eq!(app.calculate_total_object_rows(), 2);
6146
6147        // Expand folder/ with 2 items
6148        app.s3_state.expanded_prefixes.insert("folder/".to_string());
6149        app.s3_state.prefix_preview.insert(
6150            "folder/".to_string(),
6151            vec![
6152                S3Object {
6153                    key: "folder/file2.txt".to_string(),
6154                    size: 200,
6155                    last_modified: "2024-01-01T00:00:00Z".to_string(),
6156                    is_prefix: false,
6157                    storage_class: "STANDARD".to_string(),
6158                },
6159                S3Object {
6160                    key: "folder/subfolder/".to_string(),
6161                    size: 0,
6162                    last_modified: "2024-01-01T00:00:00Z".to_string(),
6163                    is_prefix: true,
6164                    storage_class: String::new(),
6165                },
6166            ],
6167        );
6168        assert_eq!(app.calculate_total_object_rows(), 4); // 2 + 2 nested
6169
6170        // Expand subfolder/ with 1 item (3rd level)
6171        app.s3_state
6172            .expanded_prefixes
6173            .insert("folder/subfolder/".to_string());
6174        app.s3_state.prefix_preview.insert(
6175            "folder/subfolder/".to_string(),
6176            vec![S3Object {
6177                key: "folder/subfolder/deep.txt".to_string(),
6178                size: 50,
6179                last_modified: "2024-01-01T00:00:00Z".to_string(),
6180                is_prefix: false,
6181                storage_class: "STANDARD".to_string(),
6182            }],
6183        );
6184        assert_eq!(app.calculate_total_object_rows(), 5); // 2 + 2 nested + 1 deep
6185    }
6186
6187    #[test]
6188    fn test_s3_object_navigation_with_deep_nesting() {
6189        let mut app = test_app();
6190        app.current_service = Service::S3Buckets;
6191        app.service_selected = true;
6192        app.mode = Mode::Normal;
6193        app.s3_state.current_bucket = Some("test-bucket".to_string());
6194
6195        // Add folder structure: folder1/ -> folder2/ -> file.txt
6196        app.s3_state.objects = vec![S3Object {
6197            key: "folder1/".to_string(),
6198            size: 0,
6199            last_modified: "2024-01-01T00:00:00Z".to_string(),
6200            is_prefix: true,
6201            storage_class: String::new(),
6202        }];
6203
6204        // Expand folder1/
6205        app.s3_state
6206            .expanded_prefixes
6207            .insert("folder1/".to_string());
6208        app.s3_state.prefix_preview.insert(
6209            "folder1/".to_string(),
6210            vec![S3Object {
6211                key: "folder1/folder2/".to_string(),
6212                size: 0,
6213                last_modified: "2024-01-01T00:00:00Z".to_string(),
6214                is_prefix: true,
6215                storage_class: String::new(),
6216            }],
6217        );
6218
6219        // Expand folder2/
6220        app.s3_state
6221            .expanded_prefixes
6222            .insert("folder1/folder2/".to_string());
6223        app.s3_state.prefix_preview.insert(
6224            "folder1/folder2/".to_string(),
6225            vec![S3Object {
6226                key: "folder1/folder2/file.txt".to_string(),
6227                size: 100,
6228                last_modified: "2024-01-01T00:00:00Z".to_string(),
6229                is_prefix: false,
6230                storage_class: "STANDARD".to_string(),
6231            }],
6232        );
6233
6234        app.s3_state.selected_object = 0;
6235
6236        // Total: folder1/ (0) + folder2/ (1) + file.txt (2) = 3 rows
6237        app.handle_action(Action::NextItem);
6238        assert_eq!(app.s3_state.selected_object, 1); // folder2/
6239
6240        app.handle_action(Action::NextItem);
6241        assert_eq!(app.s3_state.selected_object, 2); // file.txt
6242
6243        // Should not go beyond
6244        app.handle_action(Action::NextItem);
6245        assert_eq!(app.s3_state.selected_object, 2);
6246    }
6247
6248    #[test]
6249    fn test_s3_expand_nested_folder_in_objects_view() {
6250        let mut app = test_app();
6251        app.current_service = Service::S3Buckets;
6252        app.service_selected = true;
6253        app.mode = Mode::Normal;
6254        app.s3_state.current_bucket = Some("test-bucket".to_string());
6255
6256        // Add parent folder
6257        app.s3_state.objects = vec![S3Object {
6258            key: "parent/".to_string(),
6259            size: 0,
6260            last_modified: "2024-01-01T00:00:00Z".to_string(),
6261            is_prefix: true,
6262            storage_class: String::new(),
6263        }];
6264
6265        // Expand parent
6266        app.s3_state.expanded_prefixes.insert("parent/".to_string());
6267        app.s3_state.prefix_preview.insert(
6268            "parent/".to_string(),
6269            vec![S3Object {
6270                key: "parent/child/".to_string(),
6271                size: 0,
6272                last_modified: "2024-01-01T00:00:00Z".to_string(),
6273                is_prefix: true,
6274                storage_class: String::new(),
6275            }],
6276        );
6277
6278        // Select the nested folder (index 1)
6279        app.s3_state.selected_object = 1;
6280
6281        // Expand it (simulate pressing Enter/Right)
6282        app.handle_action(Action::NextPane);
6283
6284        // Should be expanded now
6285        assert!(app.s3_state.expanded_prefixes.contains("parent/child/"));
6286        assert!(app.s3_state.buckets.loading); // Should trigger load
6287    }
6288
6289    #[test]
6290    fn test_s3_drill_into_nested_folder() {
6291        let mut app = test_app();
6292        app.current_service = Service::S3Buckets;
6293        app.service_selected = true;
6294        app.mode = Mode::Normal;
6295        app.s3_state.current_bucket = Some("test-bucket".to_string());
6296
6297        // Add parent folder
6298        app.s3_state.objects = vec![S3Object {
6299            key: "parent/".to_string(),
6300            size: 0,
6301            last_modified: "2024-01-01T00:00:00Z".to_string(),
6302            is_prefix: true,
6303            storage_class: String::new(),
6304        }];
6305
6306        // Expand parent
6307        app.s3_state.expanded_prefixes.insert("parent/".to_string());
6308        app.s3_state.prefix_preview.insert(
6309            "parent/".to_string(),
6310            vec![S3Object {
6311                key: "parent/child/".to_string(),
6312                size: 0,
6313                last_modified: "2024-01-01T00:00:00Z".to_string(),
6314                is_prefix: true,
6315                storage_class: String::new(),
6316            }],
6317        );
6318
6319        // Select the nested folder (index 1)
6320        app.s3_state.selected_object = 1;
6321
6322        // Drill into it (simulate pressing Enter)
6323        app.handle_action(Action::Select);
6324
6325        // Should navigate into the folder
6326        assert_eq!(app.s3_state.prefix_stack, vec!["parent/child/".to_string()]);
6327        assert!(app.s3_state.buckets.loading); // Should trigger load
6328    }
6329
6330    #[test]
6331    fn test_s3_esc_pops_navigation_stack() {
6332        let mut app = test_app();
6333        app.current_service = Service::S3Buckets;
6334        app.s3_state.current_bucket = Some("test-bucket".to_string());
6335        app.s3_state.prefix_stack = vec!["level1/".to_string(), "level1/level2/".to_string()];
6336
6337        // Press Esc - should pop from stack
6338        app.handle_action(Action::GoBack);
6339        assert_eq!(app.s3_state.prefix_stack, vec!["level1/".to_string()]);
6340        assert!(app.s3_state.buckets.loading);
6341
6342        // Press Esc again - should pop to bucket root
6343        app.s3_state.buckets.loading = false;
6344        app.handle_action(Action::GoBack);
6345        assert_eq!(app.s3_state.prefix_stack, Vec::<String>::new());
6346        assert!(app.s3_state.buckets.loading);
6347
6348        // Press Esc again - should exit bucket
6349        app.s3_state.buckets.loading = false;
6350        app.handle_action(Action::GoBack);
6351        assert_eq!(app.s3_state.current_bucket, None);
6352    }
6353
6354    #[test]
6355    fn test_s3_esc_from_bucket_root_exits() {
6356        let mut app = test_app();
6357        app.current_service = Service::S3Buckets;
6358        app.s3_state.current_bucket = Some("test-bucket".to_string());
6359        app.s3_state.prefix_stack = vec![];
6360
6361        // Press Esc from bucket root - should exit bucket
6362        app.handle_action(Action::GoBack);
6363        assert_eq!(app.s3_state.current_bucket, None);
6364        assert_eq!(app.s3_state.objects.len(), 0);
6365    }
6366
6367    #[test]
6368    fn test_s3_drill_into_nested_prefix_from_bucket_list() {
6369        let mut app = test_app();
6370        app.current_service = Service::S3Buckets;
6371        app.service_selected = true;
6372        app.mode = Mode::Normal;
6373
6374        // Setup bucket with nested preview
6375        app.s3_state.buckets.items = vec![S3Bucket {
6376            name: "test-bucket".to_string(),
6377            region: "us-east-1".to_string(),
6378            creation_date: "2024-01-01".to_string(),
6379        }];
6380
6381        // Expand bucket to show first-level prefix
6382        app.s3_state
6383            .expanded_prefixes
6384            .insert("test-bucket".to_string());
6385        app.s3_state.bucket_preview.insert(
6386            "test-bucket".to_string(),
6387            vec![S3Object {
6388                key: "parent/".to_string(),
6389                size: 0,
6390                last_modified: "2024-01-01".to_string(),
6391                is_prefix: true,
6392                storage_class: String::new(),
6393            }],
6394        );
6395
6396        // Expand parent to show nested prefix
6397        app.s3_state.expanded_prefixes.insert("parent/".to_string());
6398        app.s3_state.prefix_preview.insert(
6399            "parent/".to_string(),
6400            vec![S3Object {
6401                key: "parent/child/".to_string(),
6402                size: 0,
6403                last_modified: "2024-01-01".to_string(),
6404                is_prefix: true,
6405                storage_class: String::new(),
6406            }],
6407        );
6408
6409        // Select nested prefix (row 2: bucket, parent, nested)
6410        app.s3_state.selected_row = 2;
6411
6412        // Drill into nested prefix
6413        app.handle_action(Action::Select);
6414
6415        // Should have both parent and child in stack
6416        assert_eq!(
6417            app.s3_state.prefix_stack,
6418            vec!["parent/".to_string(), "parent/child/".to_string()]
6419        );
6420        assert_eq!(app.s3_state.current_bucket, Some("test-bucket".to_string()));
6421        assert!(app.s3_state.buckets.loading);
6422
6423        // Now press Esc - should go back to parent
6424        app.s3_state.buckets.loading = false;
6425        app.handle_action(Action::GoBack);
6426        assert_eq!(app.s3_state.prefix_stack, vec!["parent/".to_string()]);
6427        assert!(app.s3_state.buckets.loading);
6428
6429        // Press Esc again - should go to bucket root
6430        app.s3_state.buckets.loading = false;
6431        app.handle_action(Action::GoBack);
6432        assert_eq!(app.s3_state.prefix_stack, Vec::<String>::new());
6433        assert!(app.s3_state.buckets.loading);
6434
6435        // Press Esc again - should exit bucket
6436        app.s3_state.buckets.loading = false;
6437        app.handle_action(Action::GoBack);
6438        assert_eq!(app.s3_state.current_bucket, None);
6439    }
6440
6441    #[test]
6442    fn test_region_picker_fuzzy_filter() {
6443        let mut app = test_app();
6444        app.region_latencies.insert("us-east-1".to_string(), 10);
6445        app.region_filter = "vir".to_string();
6446        let filtered = app.get_filtered_regions();
6447        assert!(filtered.iter().any(|r| r.code == "us-east-1"));
6448    }
6449
6450    #[test]
6451    fn test_profile_picker_loads_profiles() {
6452        let profiles = App::load_aws_profiles();
6453        // Should at least have default profile or be empty if no config
6454        assert!(profiles.is_empty() || profiles.iter().any(|p| p.name == "default"));
6455    }
6456
6457    #[test]
6458    fn test_profile_with_region_uses_it() {
6459        let mut app = test_app_no_region();
6460        app.available_profiles = vec![AwsProfile {
6461            name: "test-profile".to_string(),
6462            region: Some("eu-west-1".to_string()),
6463            account: Some("123456789".to_string()),
6464            role_arn: None,
6465            source_profile: None,
6466        }];
6467        app.profile_picker_selected = 0;
6468        app.mode = Mode::ProfilePicker;
6469
6470        // Simulate selecting the profile
6471        let filtered = app.get_filtered_profiles();
6472        if let Some(profile) = filtered.first() {
6473            let profile_name = profile.name.clone();
6474            let profile_region = profile.region.clone();
6475
6476            app.profile = profile_name;
6477            if let Some(region) = profile_region {
6478                app.region = region;
6479            }
6480        }
6481
6482        assert_eq!(app.profile, "test-profile");
6483        assert_eq!(app.region, "eu-west-1");
6484    }
6485
6486    #[test]
6487    fn test_profile_without_region_keeps_unknown() {
6488        let mut app = test_app_no_region();
6489        let initial_region = app.region.clone();
6490
6491        app.available_profiles = vec![AwsProfile {
6492            name: "test-profile".to_string(),
6493            region: None,
6494            account: None,
6495            role_arn: None,
6496            source_profile: None,
6497        }];
6498        app.profile_picker_selected = 0;
6499        app.mode = Mode::ProfilePicker;
6500
6501        let filtered = app.get_filtered_profiles();
6502        if let Some(profile) = filtered.first() {
6503            let profile_name = profile.name.clone();
6504            let profile_region = profile.region.clone();
6505
6506            app.profile = profile_name;
6507            if let Some(region) = profile_region {
6508                app.region = region;
6509            }
6510        }
6511
6512        assert_eq!(app.profile, "test-profile");
6513        assert_eq!(app.region, initial_region); // Should keep initial region
6514    }
6515
6516    #[test]
6517    fn test_region_selection_closes_all_tabs() {
6518        let mut app = test_app();
6519
6520        // Add some tabs
6521        app.tabs.push(Tab {
6522            service: Service::CloudWatchLogGroups,
6523            title: "CloudWatch".to_string(),
6524            breadcrumb: "CloudWatch".to_string(),
6525        });
6526        app.tabs.push(Tab {
6527            service: Service::S3Buckets,
6528            title: "S3".to_string(),
6529            breadcrumb: "S3".to_string(),
6530        });
6531        app.service_selected = true;
6532        app.current_tab = 1;
6533
6534        // Add latency for region
6535        app.region_latencies.insert("eu-west-1".to_string(), 50);
6536
6537        // Simulate selecting a different region
6538        app.mode = Mode::RegionPicker;
6539        app.region_picker_selected = 0;
6540
6541        let filtered = app.get_filtered_regions();
6542        if let Some(region) = filtered.first() {
6543            app.region = region.code.to_string();
6544            app.tabs.clear();
6545            app.current_tab = 0;
6546            app.service_selected = false;
6547            app.mode = Mode::Normal;
6548        }
6549
6550        assert_eq!(app.tabs.len(), 0);
6551        assert_eq!(app.current_tab, 0);
6552        assert!(!app.service_selected);
6553        assert_eq!(app.region, "eu-west-1");
6554    }
6555
6556    #[test]
6557    fn test_region_picker_can_be_closed_without_selection() {
6558        let mut app = test_app();
6559        let initial_region = app.region.clone();
6560
6561        app.mode = Mode::RegionPicker;
6562
6563        // Close without selecting (Esc)
6564        app.mode = Mode::Normal;
6565
6566        // Region should not change
6567        assert_eq!(app.region, initial_region);
6568    }
6569
6570    #[test]
6571    fn test_session_filter_works() {
6572        let mut app = test_app();
6573
6574        app.sessions = vec![
6575            Session {
6576                id: "1".to_string(),
6577                timestamp: "2024-01-01".to_string(),
6578                profile: "prod-profile".to_string(),
6579                region: "us-east-1".to_string(),
6580                account_id: "123456789".to_string(),
6581                role_arn: "arn:aws:iam::123456789:role/admin".to_string(),
6582                tabs: vec![],
6583            },
6584            Session {
6585                id: "2".to_string(),
6586                timestamp: "2024-01-02".to_string(),
6587                profile: "dev-profile".to_string(),
6588                region: "eu-west-1".to_string(),
6589                account_id: "987654321".to_string(),
6590                role_arn: "arn:aws:iam::987654321:role/dev".to_string(),
6591                tabs: vec![],
6592            },
6593        ];
6594
6595        // Filter by profile
6596        app.session_filter = "prod".to_string();
6597        let filtered = app.get_filtered_sessions();
6598        assert_eq!(filtered.len(), 1);
6599        assert_eq!(filtered[0].profile, "prod-profile");
6600
6601        // Filter by region
6602        app.session_filter = "eu".to_string();
6603        let filtered = app.get_filtered_sessions();
6604        assert_eq!(filtered.len(), 1);
6605        assert_eq!(filtered[0].region, "eu-west-1");
6606
6607        // No filter
6608        app.session_filter.clear();
6609        let filtered = app.get_filtered_sessions();
6610        assert_eq!(filtered.len(), 2);
6611    }
6612
6613    #[test]
6614    fn test_profile_picker_shows_account() {
6615        let mut app = test_app_no_region();
6616        app.available_profiles = vec![AwsProfile {
6617            name: "test-profile".to_string(),
6618            region: Some("us-east-1".to_string()),
6619            account: Some("123456789".to_string()),
6620            role_arn: None,
6621            source_profile: None,
6622        }];
6623
6624        let filtered = app.get_filtered_profiles();
6625        assert_eq!(filtered.len(), 1);
6626        assert_eq!(filtered[0].account, Some("123456789".to_string()));
6627    }
6628
6629    #[test]
6630    fn test_profile_without_account() {
6631        let mut app = test_app_no_region();
6632        app.available_profiles = vec![AwsProfile {
6633            name: "test-profile".to_string(),
6634            region: Some("us-east-1".to_string()),
6635            account: None,
6636            role_arn: None,
6637            source_profile: None,
6638        }];
6639
6640        let filtered = app.get_filtered_profiles();
6641        assert_eq!(filtered.len(), 1);
6642        assert_eq!(filtered[0].account, None);
6643    }
6644
6645    #[test]
6646    fn test_profile_with_all_fields() {
6647        let mut app = test_app_no_region();
6648        app.available_profiles = vec![AwsProfile {
6649            name: "prod-profile".to_string(),
6650            region: Some("us-west-2".to_string()),
6651            account: Some("123456789".to_string()),
6652            role_arn: Some("arn:aws:iam::123456789:role/AdminRole".to_string()),
6653            source_profile: Some("base-profile".to_string()),
6654        }];
6655
6656        let filtered = app.get_filtered_profiles();
6657        assert_eq!(filtered.len(), 1);
6658        assert_eq!(filtered[0].name, "prod-profile");
6659        assert_eq!(filtered[0].region, Some("us-west-2".to_string()));
6660        assert_eq!(filtered[0].account, Some("123456789".to_string()));
6661        assert_eq!(
6662            filtered[0].role_arn,
6663            Some("arn:aws:iam::123456789:role/AdminRole".to_string())
6664        );
6665        assert_eq!(filtered[0].source_profile, Some("base-profile".to_string()));
6666    }
6667
6668    #[test]
6669    fn test_profile_filter_by_source_profile() {
6670        let mut app = test_app_no_region();
6671        app.available_profiles = vec![
6672            AwsProfile {
6673                name: "profile1".to_string(),
6674                region: None,
6675                account: None,
6676                role_arn: None,
6677                source_profile: Some("base".to_string()),
6678            },
6679            AwsProfile {
6680                name: "profile2".to_string(),
6681                region: None,
6682                account: None,
6683                role_arn: None,
6684                source_profile: Some("other".to_string()),
6685            },
6686        ];
6687
6688        app.profile_filter = "base".to_string();
6689        let filtered = app.get_filtered_profiles();
6690        assert_eq!(filtered.len(), 1);
6691        assert_eq!(filtered[0].name, "profile1");
6692    }
6693
6694    #[test]
6695    fn test_profile_filter_by_role() {
6696        let mut app = test_app_no_region();
6697        app.available_profiles = vec![
6698            AwsProfile {
6699                name: "admin-profile".to_string(),
6700                region: None,
6701                account: None,
6702                role_arn: Some("arn:aws:iam::123:role/AdminRole".to_string()),
6703                source_profile: None,
6704            },
6705            AwsProfile {
6706                name: "dev-profile".to_string(),
6707                region: None,
6708                account: None,
6709                role_arn: Some("arn:aws:iam::123:role/DevRole".to_string()),
6710                source_profile: None,
6711            },
6712        ];
6713
6714        app.profile_filter = "Admin".to_string();
6715        let filtered = app.get_filtered_profiles();
6716        assert_eq!(filtered.len(), 1);
6717        assert_eq!(filtered[0].name, "admin-profile");
6718    }
6719
6720    #[test]
6721    fn test_profiles_sorted_by_name() {
6722        let mut app = test_app_no_region();
6723        app.available_profiles = vec![
6724            AwsProfile {
6725                name: "zebra-profile".to_string(),
6726                region: None,
6727                account: None,
6728                role_arn: None,
6729                source_profile: None,
6730            },
6731            AwsProfile {
6732                name: "alpha-profile".to_string(),
6733                region: None,
6734                account: None,
6735                role_arn: None,
6736                source_profile: None,
6737            },
6738            AwsProfile {
6739                name: "beta-profile".to_string(),
6740                region: None,
6741                account: None,
6742                role_arn: None,
6743                source_profile: None,
6744            },
6745        ];
6746
6747        let filtered = app.get_filtered_profiles();
6748        assert_eq!(filtered.len(), 3);
6749        assert_eq!(filtered[0].name, "alpha-profile");
6750        assert_eq!(filtered[1].name, "beta-profile");
6751        assert_eq!(filtered[2].name, "zebra-profile");
6752    }
6753
6754    #[test]
6755    fn test_profile_with_role_arn() {
6756        let mut app = test_app_no_region();
6757        app.available_profiles = vec![AwsProfile {
6758            name: "role-profile".to_string(),
6759            region: Some("us-east-1".to_string()),
6760            account: Some("123456789".to_string()),
6761            role_arn: Some("arn:aws:iam::123456789:role/AdminRole".to_string()),
6762            source_profile: None,
6763        }];
6764
6765        let filtered = app.get_filtered_profiles();
6766        assert_eq!(filtered.len(), 1);
6767        assert!(filtered[0].role_arn.as_ref().unwrap().contains(":role/"));
6768    }
6769
6770    #[test]
6771    fn test_profile_with_user_arn() {
6772        let mut app = test_app_no_region();
6773        app.available_profiles = vec![AwsProfile {
6774            name: "user-profile".to_string(),
6775            region: Some("us-east-1".to_string()),
6776            account: Some("123456789".to_string()),
6777            role_arn: Some("arn:aws:iam::123456789:user/john-doe".to_string()),
6778            source_profile: None,
6779        }];
6780
6781        let filtered = app.get_filtered_profiles();
6782        assert_eq!(filtered.len(), 1);
6783        assert!(filtered[0].role_arn.as_ref().unwrap().contains(":user/"));
6784    }
6785
6786    #[test]
6787    fn test_filtered_profiles_also_sorted() {
6788        let mut app = test_app_no_region();
6789        app.available_profiles = vec![
6790            AwsProfile {
6791                name: "prod-zebra".to_string(),
6792                region: Some("us-east-1".to_string()),
6793                account: None,
6794                role_arn: None,
6795                source_profile: None,
6796            },
6797            AwsProfile {
6798                name: "prod-alpha".to_string(),
6799                region: Some("us-east-1".to_string()),
6800                account: None,
6801                role_arn: None,
6802                source_profile: None,
6803            },
6804            AwsProfile {
6805                name: "dev-profile".to_string(),
6806                region: Some("us-west-2".to_string()),
6807                account: None,
6808                role_arn: None,
6809                source_profile: None,
6810            },
6811        ];
6812
6813        app.profile_filter = "prod".to_string();
6814        let filtered = app.get_filtered_profiles();
6815        assert_eq!(filtered.len(), 2);
6816        assert_eq!(filtered[0].name, "prod-alpha");
6817        assert_eq!(filtered[1].name, "prod-zebra");
6818    }
6819
6820    #[test]
6821    fn test_profile_picker_has_all_columns() {
6822        let mut app = test_app_no_region();
6823        app.available_profiles = vec![AwsProfile {
6824            name: "test".to_string(),
6825            region: Some("us-east-1".to_string()),
6826            account: Some("123456789".to_string()),
6827            role_arn: Some("arn:aws:iam::123456789:role/Admin".to_string()),
6828            source_profile: Some("base".to_string()),
6829        }];
6830
6831        let filtered = app.get_filtered_profiles();
6832        assert_eq!(filtered.len(), 1);
6833        assert!(filtered[0].name == "test");
6834        assert!(filtered[0].region.is_some());
6835        assert!(filtered[0].account.is_some());
6836        assert!(filtered[0].role_arn.is_some());
6837        assert!(filtered[0].source_profile.is_some());
6838    }
6839
6840    #[test]
6841    fn test_session_picker_shows_tab_count() {
6842        let mut app = test_app_no_region();
6843        app.sessions = vec![Session {
6844            id: "1".to_string(),
6845            timestamp: "2024-01-01".to_string(),
6846            profile: "test".to_string(),
6847            region: "us-east-1".to_string(),
6848            account_id: "123".to_string(),
6849            role_arn: String::new(),
6850            tabs: vec![
6851                SessionTab {
6852                    service: "CloudWatch".to_string(),
6853                    title: "Logs".to_string(),
6854                    breadcrumb: String::new(),
6855                    filter: None,
6856                    selected_item: None,
6857                },
6858                SessionTab {
6859                    service: "S3".to_string(),
6860                    title: "Buckets".to_string(),
6861                    breadcrumb: String::new(),
6862                    filter: None,
6863                    selected_item: None,
6864                },
6865            ],
6866        }];
6867
6868        let filtered = app.get_filtered_sessions();
6869        assert_eq!(filtered.len(), 1);
6870        assert_eq!(filtered[0].tabs.len(), 2);
6871    }
6872
6873    #[test]
6874    fn test_start_background_data_fetch_loads_profiles() {
6875        let mut app = test_app_no_region();
6876        assert!(app.available_profiles.is_empty());
6877
6878        // Load profiles synchronously
6879        app.available_profiles = App::load_aws_profiles();
6880
6881        // Profiles should be loaded
6882        assert!(!app.available_profiles.is_empty() || app.available_profiles.is_empty());
6883    }
6884
6885    #[test]
6886    fn test_refresh_in_profile_picker() {
6887        let mut app = test_app_no_region();
6888        app.mode = Mode::ProfilePicker;
6889        app.available_profiles = vec![AwsProfile {
6890            name: "test".to_string(),
6891            region: None,
6892            account: None,
6893            role_arn: None,
6894            source_profile: None,
6895        }];
6896
6897        app.handle_action(Action::Refresh);
6898
6899        // Should set loading state — no Refreshing... message (removed: it leaked into other services)
6900        assert!(app.log_groups_state.loading);
6901        assert!(
6902            app.log_groups_state.loading_message.is_empty()
6903                || !app.log_groups_state.loading_message.contains("Refreshing"),
6904            "loading_message must not say 'Refreshing...' as it leaks into other services"
6905        );
6906    }
6907
6908    #[test]
6909    fn test_refresh_sets_loading_for_profile_picker() {
6910        let mut app = test_app_no_region();
6911        app.mode = Mode::ProfilePicker;
6912
6913        assert!(!app.log_groups_state.loading);
6914
6915        app.handle_action(Action::Refresh);
6916
6917        assert!(app.log_groups_state.loading);
6918    }
6919
6920    #[test]
6921    fn test_profiles_loaded_on_demand() {
6922        let mut app = test_app_no_region();
6923
6924        // Profiles not loaded by default
6925        assert!(app.available_profiles.is_empty());
6926
6927        // Load on demand
6928        app.available_profiles = App::load_aws_profiles();
6929
6930        // Now loaded
6931        assert!(!app.available_profiles.is_empty() || app.available_profiles.is_empty());
6932    }
6933
6934    #[test]
6935    fn test_profile_accounts_not_fetched_automatically() {
6936        let mut app = test_app_no_region();
6937        app.available_profiles = App::load_aws_profiles();
6938
6939        // Accounts should not be populated automatically
6940        for profile in &app.available_profiles {
6941            // Account may or may not be set depending on what's in config
6942            // But we're not fetching them automatically
6943            assert!(profile.account.is_none() || profile.account.is_some());
6944        }
6945    }
6946
6947    #[test]
6948    fn test_ctrl_r_triggers_account_fetch() {
6949        let mut app = test_app_no_region();
6950        app.mode = Mode::ProfilePicker;
6951        app.available_profiles = vec![AwsProfile {
6952            name: "test".to_string(),
6953            region: Some("us-east-1".to_string()),
6954            account: None,
6955            role_arn: None,
6956            source_profile: None,
6957        }];
6958
6959        // Before refresh, account is None
6960        assert!(app.available_profiles[0].account.is_none());
6961
6962        // Trigger refresh
6963        app.handle_action(Action::Refresh);
6964
6965        // Should set loading state (actual fetch happens in main.rs event loop)
6966        assert!(app.log_groups_state.loading);
6967    }
6968
6969    #[test]
6970    fn test_refresh_in_region_picker() {
6971        let mut app = test_app_no_region();
6972        app.mode = Mode::RegionPicker;
6973
6974        let initial_latencies = app.region_latencies.len();
6975        app.handle_action(Action::Refresh);
6976
6977        // Latencies should be cleared and remeasured
6978        assert!(app.region_latencies.is_empty() || app.region_latencies.len() >= initial_latencies);
6979    }
6980
6981    #[test]
6982    fn test_refresh_in_session_picker() {
6983        let mut app = test_app_no_region();
6984        app.mode = Mode::SessionPicker;
6985        app.sessions = vec![];
6986
6987        app.handle_action(Action::Refresh);
6988
6989        // Sessions should be reloaded (may be empty if no saved sessions)
6990        assert!(app.sessions.is_empty() || !app.sessions.is_empty());
6991    }
6992
6993    #[test]
6994    fn test_session_picker_selection() {
6995        let mut app = test_app();
6996
6997        app.sessions = vec![Session {
6998            id: "1".to_string(),
6999            timestamp: "2024-01-01".to_string(),
7000            profile: "prod-profile".to_string(),
7001            region: "us-west-2".to_string(),
7002            account_id: "123456789".to_string(),
7003            role_arn: "arn:aws:iam::123456789:role/admin".to_string(),
7004            tabs: vec![SessionTab {
7005                service: "CloudWatchLogGroups".to_string(),
7006                title: "Log Groups".to_string(),
7007                breadcrumb: "CloudWatch › Log Groups".to_string(),
7008                filter: Some("test".to_string()),
7009                selected_item: None,
7010            }],
7011        }];
7012
7013        app.mode = Mode::SessionPicker;
7014        app.session_picker_selected = 0;
7015
7016        // Simulate selecting the session
7017        app.handle_action(Action::Select);
7018
7019        assert_eq!(app.mode, Mode::Normal);
7020        assert_eq!(app.profile, "prod-profile");
7021        assert_eq!(app.region, "us-west-2");
7022        assert_eq!(app.config.account_id, "123456789");
7023        assert_eq!(app.tabs.len(), 1);
7024        assert_eq!(app.tabs[0].title, "Log Groups");
7025    }
7026
7027    #[test]
7028    fn test_save_session_creates_session() {
7029        let mut app =
7030            App::new_without_client("test-profile".to_string(), Some("us-east-1".to_string()));
7031        app.config.account_id = "123456789".to_string();
7032        app.config.role_arn = "arn:aws:iam::123456789:role/test".to_string();
7033
7034        app.tabs.push(Tab {
7035            service: Service::CloudWatchLogGroups,
7036            title: "Log Groups".to_string(),
7037            breadcrumb: "CloudWatch › Log Groups".to_string(),
7038        });
7039
7040        app.save_current_session();
7041
7042        assert!(app.current_session.is_some());
7043        let session = app.current_session.clone().unwrap();
7044        assert_eq!(session.profile, "test-profile");
7045        assert_eq!(session.region, "us-east-1");
7046        assert_eq!(session.account_id, "123456789");
7047        assert_eq!(session.tabs.len(), 1);
7048
7049        // Cleanup
7050        let _ = session.delete();
7051    }
7052
7053    #[test]
7054    fn test_save_session_updates_existing() {
7055        let mut app =
7056            App::new_without_client("test-profile".to_string(), Some("us-east-1".to_string()));
7057        app.config.account_id = "123456789".to_string();
7058        app.config.role_arn = "arn:aws:iam::123456789:role/test".to_string();
7059
7060        app.current_session = Some(Session {
7061            id: "existing".to_string(),
7062            timestamp: "2024-01-01".to_string(),
7063            profile: "test-profile".to_string(),
7064            region: "us-east-1".to_string(),
7065            account_id: "123456789".to_string(),
7066            role_arn: "arn:aws:iam::123456789:role/test".to_string(),
7067            tabs: vec![],
7068        });
7069
7070        app.tabs.push(Tab {
7071            service: Service::CloudWatchLogGroups,
7072            title: "Log Groups".to_string(),
7073            breadcrumb: "CloudWatch › Log Groups".to_string(),
7074        });
7075
7076        app.save_current_session();
7077
7078        let session = app.current_session.clone().unwrap();
7079        assert_eq!(session.id, "existing");
7080        assert_eq!(session.tabs.len(), 1);
7081
7082        // Cleanup
7083        let _ = session.delete();
7084    }
7085
7086    #[test]
7087    fn test_save_session_skips_empty_tabs() {
7088        let mut app =
7089            App::new_without_client("test-profile".to_string(), Some("us-east-1".to_string()));
7090        app.config.account_id = "123456789".to_string();
7091
7092        app.save_current_session();
7093
7094        assert!(app.current_session.is_none());
7095    }
7096
7097    #[test]
7098    fn test_save_session_deletes_when_tabs_closed() {
7099        let mut app =
7100            App::new_without_client("test-profile".to_string(), Some("us-east-1".to_string()));
7101        app.config.account_id = "123456789".to_string();
7102        app.config.role_arn = "arn:aws:iam::123456789:role/test".to_string();
7103
7104        // Create a session with tabs
7105        app.current_session = Some(Session {
7106            id: "test_delete".to_string(),
7107            timestamp: "2024-01-01 10:00:00 UTC".to_string(),
7108            profile: "test-profile".to_string(),
7109            region: "us-east-1".to_string(),
7110            account_id: "123456789".to_string(),
7111            role_arn: "arn:aws:iam::123456789:role/test".to_string(),
7112            tabs: vec![],
7113        });
7114
7115        // Save with no tabs should delete session
7116        app.save_current_session();
7117
7118        assert!(app.current_session.is_none());
7119    }
7120
7121    #[test]
7122    fn test_closing_all_tabs_deletes_session() {
7123        let mut app =
7124            App::new_without_client("test-profile".to_string(), Some("us-east-1".to_string()));
7125        app.config.account_id = "123456789".to_string();
7126        app.config.role_arn = "arn:aws:iam::123456789:role/test".to_string();
7127
7128        // Add a tab
7129        app.tabs.push(Tab {
7130            service: Service::CloudWatchLogGroups,
7131            title: "Log Groups".to_string(),
7132            breadcrumb: "CloudWatch › Log Groups".to_string(),
7133        });
7134
7135        // Create session
7136        app.save_current_session();
7137        assert!(app.current_session.is_some());
7138        let session_id = app.current_session.as_ref().unwrap().id.clone();
7139
7140        // Close all tabs
7141        app.tabs.clear();
7142
7143        // Save should delete session
7144        app.save_current_session();
7145        assert!(app.current_session.is_none());
7146
7147        // Cleanup - ensure session file is deleted
7148        let _ = Session::load(&session_id).map(|s| s.delete());
7149    }
7150
7151    #[test]
7152    fn test_credential_error_opens_profile_picker() {
7153        // Simulate what main.rs does on credential error
7154        let mut app = App::new_without_client("default".to_string(), None);
7155        let error_str = "Unable to load credentials from any source";
7156
7157        if error_str.contains("credentials") {
7158            app.available_profiles = App::load_aws_profiles();
7159            app.mode = Mode::ProfilePicker;
7160        }
7161
7162        assert_eq!(app.mode, Mode::ProfilePicker);
7163        // Should have loaded profiles
7164        assert!(!app.available_profiles.is_empty() || app.available_profiles.is_empty());
7165    }
7166
7167    #[test]
7168    fn test_non_credential_error_shows_error_modal() {
7169        let mut app = App::new_without_client("default".to_string(), None);
7170        let error_str = "Network timeout";
7171
7172        if !error_str.contains("credentials") {
7173            app.error_message = Some(error_str.to_string());
7174            app.mode = Mode::ErrorModal;
7175        }
7176
7177        assert_eq!(app.mode, Mode::ErrorModal);
7178        assert!(app.error_message.is_some());
7179    }
7180
7181    #[tokio::test]
7182    async fn test_profile_selection_loads_credentials() {
7183        // Set a valid AWS profile if available
7184        std::env::set_var("AWS_PROFILE", "default");
7185
7186        // Try to create app with profile
7187        let result = App::new(Some("default".to_string()), Some("us-east-1".to_string())).await;
7188
7189        if let Ok(app) = result {
7190            // If credentials are available, verify they're loaded
7191            assert!(!app.config.account_id.is_empty());
7192            assert!(!app.config.role_arn.is_empty());
7193            assert_eq!(app.profile, "default");
7194            assert_eq!(app.config.region, "us-east-1");
7195        }
7196        // If no credentials, test passes (can't test without real AWS creds)
7197    }
7198
7199    #[test]
7200    fn test_new_app_shows_service_picker_with_no_tabs() {
7201        let app = App::new_without_client("default".to_string(), Some("us-east-1".to_string()));
7202
7203        // Should start with no service selected
7204        assert!(!app.service_selected);
7205        // Should be in ServicePicker mode (service picker)
7206        assert_eq!(app.mode, Mode::ServicePicker);
7207        // Should have no tabs
7208        assert!(app.tabs.is_empty());
7209    }
7210
7211    #[tokio::test]
7212    async fn test_aws_profile_env_var_read_before_config_load() {
7213        // This test verifies the bug: AWS_PROFILE should be read and used
7214        std::env::set_var("AWS_PROFILE", "test-profile");
7215
7216        // Simulate what happens in App::new
7217        let profile_name = None
7218            .or_else(|| std::env::var("AWS_PROFILE").ok())
7219            .unwrap_or_else(|| "default".to_string());
7220
7221        // Should have read test-profile from env
7222        assert_eq!(profile_name, "test-profile");
7223
7224        // Now set it (redundant but that's what the code does)
7225        std::env::set_var("AWS_PROFILE", &profile_name);
7226
7227        // Verify it's still set
7228        assert_eq!(std::env::var("AWS_PROFILE").unwrap(), "test-profile");
7229
7230        std::env::remove_var("AWS_PROFILE");
7231    }
7232
7233    #[test]
7234    fn test_next_preferences_cloudformation() {
7235        let mut app = test_app();
7236        app.current_service = Service::CloudFormationStacks;
7237        app.mode = Mode::ColumnSelector;
7238        app.column_selector_index = 0;
7239
7240        // Should jump to PageSize section
7241        let page_size_idx = app.cfn_column_ids.len() + 2;
7242        app.handle_action(Action::NextPreferences);
7243        assert_eq!(app.column_selector_index, page_size_idx);
7244
7245        // Should wrap back to Columns
7246        app.handle_action(Action::NextPreferences);
7247        assert_eq!(app.column_selector_index, 0);
7248    }
7249
7250    #[test]
7251    fn test_s3_preferences_tab_cycling() {
7252        let mut app = test_app();
7253        app.current_service = Service::S3Buckets;
7254        app.mode = Mode::ColumnSelector;
7255        app.column_selector_index = 0;
7256
7257        let page_size_idx = app.s3_bucket_column_ids.len() + 2;
7258
7259        // Tab should jump to PageSize
7260        app.handle_action(Action::NextPreferences);
7261        assert_eq!(app.column_selector_index, page_size_idx);
7262
7263        // Tab should wrap back to Columns
7264        app.handle_action(Action::NextPreferences);
7265        assert_eq!(app.column_selector_index, 0);
7266
7267        // Shift+Tab should jump to PageSize
7268        app.handle_action(Action::PrevPreferences);
7269        assert_eq!(app.column_selector_index, page_size_idx);
7270
7271        // Shift+Tab should wrap back to Columns
7272        app.handle_action(Action::PrevPreferences);
7273        assert_eq!(app.column_selector_index, 0);
7274    }
7275
7276    #[test]
7277    fn test_s3_filter_resets_selection() {
7278        let mut app = test_app();
7279        app.current_service = Service::S3Buckets;
7280        app.service_selected = true;
7281
7282        // Add some buckets
7283        app.s3_state.buckets.items = vec![
7284            S3Bucket {
7285                name: "bucket-1".to_string(),
7286                region: "us-east-1".to_string(),
7287                creation_date: "2023-01-01".to_string(),
7288            },
7289            S3Bucket {
7290                name: "bucket-2".to_string(),
7291                region: "us-east-1".to_string(),
7292                creation_date: "2023-01-02".to_string(),
7293            },
7294            S3Bucket {
7295                name: "other-bucket".to_string(),
7296                region: "us-east-1".to_string(),
7297                creation_date: "2023-01-03".to_string(),
7298            },
7299        ];
7300
7301        // Navigate to second bucket
7302        app.s3_state.selected_row = 1;
7303        app.s3_state.bucket_scroll_offset = 1;
7304
7305        // Apply filter
7306        app.mode = Mode::FilterInput;
7307        app.apply_filter_operation(|f| f.push_str("bucket-"));
7308
7309        // Selection should be reset
7310        assert_eq!(app.s3_state.selected_row, 0);
7311        assert_eq!(app.s3_state.bucket_scroll_offset, 0);
7312        assert_eq!(app.s3_state.buckets.filter, "bucket-");
7313    }
7314
7315    #[test]
7316    fn test_s3_navigation_respects_filter() {
7317        let mut app = test_app();
7318        app.current_service = Service::S3Buckets;
7319        app.service_selected = true;
7320        app.mode = Mode::Normal;
7321
7322        // Add buckets
7323        app.s3_state.buckets.items = vec![
7324            S3Bucket {
7325                name: "prod-bucket".to_string(),
7326                region: "us-east-1".to_string(),
7327                creation_date: "2023-01-01".to_string(),
7328            },
7329            S3Bucket {
7330                name: "dev-bucket".to_string(),
7331                region: "us-east-1".to_string(),
7332                creation_date: "2023-01-02".to_string(),
7333            },
7334            S3Bucket {
7335                name: "prod-logs".to_string(),
7336                region: "us-east-1".to_string(),
7337                creation_date: "2023-01-03".to_string(),
7338            },
7339        ];
7340
7341        // Filter to only "prod" buckets (2 results)
7342        app.s3_state.buckets.filter = "prod".to_string();
7343
7344        // Should start at 0
7345        assert_eq!(app.s3_state.selected_row, 0);
7346
7347        // Navigate down - should go to row 1 (prod-logs)
7348        app.handle_action(Action::NextItem);
7349        assert_eq!(app.s3_state.selected_row, 1);
7350
7351        // Navigate down again - should stay at 1 (max for 2 filtered results)
7352        app.handle_action(Action::NextItem);
7353        assert_eq!(app.s3_state.selected_row, 1);
7354
7355        // Navigate up - should go back to 0
7356        app.handle_action(Action::PrevItem);
7357        assert_eq!(app.s3_state.selected_row, 0);
7358    }
7359
7360    #[test]
7361    fn test_next_preferences_lambda_functions() {
7362        let mut app = test_app();
7363        app.current_service = Service::LambdaFunctions;
7364        app.mode = Mode::ColumnSelector;
7365        app.column_selector_index = 0;
7366
7367        let page_size_idx = app.lambda_state.function_column_ids.len() + 2;
7368        app.handle_action(Action::NextPreferences);
7369        assert_eq!(app.column_selector_index, page_size_idx);
7370
7371        app.handle_action(Action::NextPreferences);
7372        assert_eq!(app.column_selector_index, 0);
7373    }
7374
7375    #[test]
7376    fn test_next_preferences_lambda_applications() {
7377        let mut app = test_app();
7378        app.current_service = Service::LambdaApplications;
7379        app.mode = Mode::ColumnSelector;
7380        app.column_selector_index = 0;
7381
7382        let page_size_idx = app.lambda_application_column_ids.len() + 2;
7383        app.handle_action(Action::NextPreferences);
7384        assert_eq!(app.column_selector_index, page_size_idx);
7385
7386        app.handle_action(Action::NextPreferences);
7387        assert_eq!(app.column_selector_index, 0);
7388    }
7389
7390    #[test]
7391    fn test_next_preferences_ecr_images() {
7392        let mut app = test_app();
7393        app.current_service = Service::EcrRepositories;
7394        app.ecr_state.current_repository = Some("test-repo".to_string());
7395        app.mode = Mode::ColumnSelector;
7396        app.column_selector_index = 0;
7397
7398        let page_size_idx = app.ecr_image_column_ids.len() + 2;
7399        app.handle_action(Action::NextPreferences);
7400        assert_eq!(app.column_selector_index, page_size_idx);
7401
7402        app.handle_action(Action::NextPreferences);
7403        assert_eq!(app.column_selector_index, 0);
7404    }
7405
7406    #[test]
7407    fn test_next_preferences_cloudwatch_log_groups() {
7408        let mut app = test_app();
7409        app.current_service = Service::CloudWatchLogGroups;
7410        app.view_mode = ViewMode::List;
7411        app.mode = Mode::ColumnSelector;
7412        app.column_selector_index = 0;
7413
7414        // Tab from Columns to PageSize
7415        let page_size_idx = app.cw_log_group_column_ids.len() + 2;
7416        app.handle_action(Action::NextPreferences);
7417        assert_eq!(app.column_selector_index, page_size_idx);
7418
7419        // Tab from PageSize back to Columns
7420        app.handle_action(Action::NextPreferences);
7421        assert_eq!(app.column_selector_index, 0);
7422
7423        // Shift+Tab from Columns to PageSize
7424        app.handle_action(Action::PrevPreferences);
7425        assert_eq!(app.column_selector_index, page_size_idx);
7426
7427        // Shift+Tab from PageSize back to Columns
7428        app.handle_action(Action::PrevPreferences);
7429        assert_eq!(app.column_selector_index, 0);
7430    }
7431
7432    #[test]
7433    fn test_next_preferences_cloudwatch_log_streams() {
7434        let mut app = test_app();
7435        app.current_service = Service::CloudWatchLogGroups;
7436        app.view_mode = ViewMode::Detail;
7437        app.mode = Mode::ColumnSelector;
7438        app.column_selector_index = 0;
7439
7440        // Tab from Columns to PageSize
7441        let page_size_idx = app.cw_log_stream_column_ids.len() + 2;
7442        app.handle_action(Action::NextPreferences);
7443        assert_eq!(app.column_selector_index, page_size_idx);
7444
7445        // Tab from PageSize back to Columns
7446        app.handle_action(Action::NextPreferences);
7447        assert_eq!(app.column_selector_index, 0);
7448    }
7449
7450    #[test]
7451    fn test_cloudformation_next_item() {
7452        let mut app = test_app();
7453        app.current_service = Service::CloudFormationStacks;
7454        app.service_selected = true;
7455        app.mode = Mode::Normal;
7456        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7457        app.cfn_state.table.items = vec![
7458            CfnStack {
7459                name: "stack1".to_string(),
7460                stack_id: "id1".to_string(),
7461                status: "CREATE_COMPLETE".to_string(),
7462                created_time: "2024-01-01".to_string(),
7463                updated_time: String::new(),
7464                deleted_time: String::new(),
7465                drift_status: String::new(),
7466                last_drift_check_time: String::new(),
7467                status_reason: String::new(),
7468                description: String::new(),
7469                detailed_status: String::new(),
7470                root_stack: String::new(),
7471                parent_stack: String::new(),
7472                termination_protection: false,
7473                iam_role: String::new(),
7474                tags: Vec::new(),
7475                stack_policy: String::new(),
7476                rollback_monitoring_time: String::new(),
7477                rollback_alarms: Vec::new(),
7478                notification_arns: Vec::new(),
7479            },
7480            CfnStack {
7481                name: "stack2".to_string(),
7482                stack_id: "id2".to_string(),
7483                status: "UPDATE_COMPLETE".to_string(),
7484                created_time: "2024-01-02".to_string(),
7485                updated_time: String::new(),
7486                deleted_time: String::new(),
7487                drift_status: String::new(),
7488                last_drift_check_time: String::new(),
7489                status_reason: String::new(),
7490                description: String::new(),
7491                detailed_status: String::new(),
7492                root_stack: String::new(),
7493                parent_stack: String::new(),
7494                termination_protection: false,
7495                iam_role: String::new(),
7496                tags: Vec::new(),
7497                stack_policy: String::new(),
7498                rollback_monitoring_time: String::new(),
7499                rollback_alarms: Vec::new(),
7500                notification_arns: Vec::new(),
7501            },
7502        ];
7503        app.cfn_state.table.reset();
7504
7505        app.handle_action(Action::NextItem);
7506        assert_eq!(app.cfn_state.table.selected, 1);
7507
7508        app.handle_action(Action::NextItem);
7509        assert_eq!(app.cfn_state.table.selected, 1); // At max
7510    }
7511
7512    #[test]
7513    fn test_cloudformation_prev_item() {
7514        let mut app = test_app();
7515        app.current_service = Service::CloudFormationStacks;
7516        app.service_selected = true;
7517        app.mode = Mode::Normal;
7518        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7519        app.cfn_state.table.items = vec![
7520            CfnStack {
7521                name: "stack1".to_string(),
7522                stack_id: "id1".to_string(),
7523                status: "CREATE_COMPLETE".to_string(),
7524                created_time: "2024-01-01".to_string(),
7525                updated_time: String::new(),
7526                deleted_time: String::new(),
7527                drift_status: String::new(),
7528                last_drift_check_time: String::new(),
7529                status_reason: String::new(),
7530                description: String::new(),
7531                detailed_status: String::new(),
7532                root_stack: String::new(),
7533                parent_stack: String::new(),
7534                termination_protection: false,
7535                iam_role: String::new(),
7536                tags: Vec::new(),
7537                stack_policy: String::new(),
7538                rollback_monitoring_time: String::new(),
7539                rollback_alarms: Vec::new(),
7540                notification_arns: Vec::new(),
7541            },
7542            CfnStack {
7543                name: "stack2".to_string(),
7544                stack_id: "id2".to_string(),
7545                status: "UPDATE_COMPLETE".to_string(),
7546                created_time: "2024-01-02".to_string(),
7547                updated_time: String::new(),
7548                deleted_time: String::new(),
7549                drift_status: String::new(),
7550                last_drift_check_time: String::new(),
7551                status_reason: String::new(),
7552                description: String::new(),
7553                detailed_status: String::new(),
7554                root_stack: String::new(),
7555                parent_stack: String::new(),
7556                termination_protection: false,
7557                iam_role: String::new(),
7558                tags: Vec::new(),
7559                stack_policy: String::new(),
7560                rollback_monitoring_time: String::new(),
7561                rollback_alarms: Vec::new(),
7562                notification_arns: Vec::new(),
7563            },
7564        ];
7565        app.cfn_state.table.selected = 1;
7566
7567        app.handle_action(Action::PrevItem);
7568        assert_eq!(app.cfn_state.table.selected, 0);
7569
7570        app.handle_action(Action::PrevItem);
7571        assert_eq!(app.cfn_state.table.selected, 0); // At min
7572    }
7573
7574    #[test]
7575    fn test_cloudformation_page_down() {
7576        let mut app = test_app();
7577        app.current_service = Service::CloudFormationStacks;
7578        app.service_selected = true;
7579        app.mode = Mode::Normal;
7580        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7581
7582        // Create 20 stacks
7583        for i in 0..20 {
7584            app.cfn_state.table.items.push(CfnStack {
7585                name: format!("stack{}", i),
7586                stack_id: format!("id{}", i),
7587                status: "CREATE_COMPLETE".to_string(),
7588                created_time: format!("2024-01-{:02}", i + 1),
7589                updated_time: String::new(),
7590                deleted_time: String::new(),
7591                drift_status: String::new(),
7592                last_drift_check_time: String::new(),
7593                status_reason: String::new(),
7594                description: String::new(),
7595                detailed_status: String::new(),
7596                root_stack: String::new(),
7597                parent_stack: String::new(),
7598                termination_protection: false,
7599                iam_role: String::new(),
7600                tags: Vec::new(),
7601                stack_policy: String::new(),
7602                rollback_monitoring_time: String::new(),
7603                rollback_alarms: Vec::new(),
7604                notification_arns: Vec::new(),
7605            });
7606        }
7607        app.cfn_state.table.reset();
7608
7609        app.handle_action(Action::PageDown);
7610        assert_eq!(app.cfn_state.table.selected, 10);
7611
7612        app.handle_action(Action::PageDown);
7613        assert_eq!(app.cfn_state.table.selected, 19); // Clamped to max
7614    }
7615
7616    #[test]
7617    fn test_cloudformation_page_up() {
7618        let mut app = test_app();
7619        app.current_service = Service::CloudFormationStacks;
7620        app.service_selected = true;
7621        app.mode = Mode::Normal;
7622        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7623
7624        // Create 20 stacks
7625        for i in 0..20 {
7626            app.cfn_state.table.items.push(CfnStack {
7627                name: format!("stack{}", i),
7628                stack_id: format!("id{}", i),
7629                status: "CREATE_COMPLETE".to_string(),
7630                created_time: format!("2024-01-{:02}", i + 1),
7631                updated_time: String::new(),
7632                deleted_time: String::new(),
7633                drift_status: String::new(),
7634                last_drift_check_time: String::new(),
7635                status_reason: String::new(),
7636                description: String::new(),
7637                detailed_status: String::new(),
7638                root_stack: String::new(),
7639                parent_stack: String::new(),
7640                termination_protection: false,
7641                iam_role: String::new(),
7642                tags: Vec::new(),
7643                stack_policy: String::new(),
7644                rollback_monitoring_time: String::new(),
7645                rollback_alarms: Vec::new(),
7646                notification_arns: Vec::new(),
7647            });
7648        }
7649        app.cfn_state.table.selected = 15;
7650
7651        app.handle_action(Action::PageUp);
7652        assert_eq!(app.cfn_state.table.selected, 5);
7653
7654        app.handle_action(Action::PageUp);
7655        assert_eq!(app.cfn_state.table.selected, 0); // Clamped to min
7656    }
7657
7658    #[test]
7659    fn test_cloudformation_filter_input() {
7660        let mut app = test_app();
7661        app.current_service = Service::CloudFormationStacks;
7662        app.service_selected = true;
7663        app.mode = Mode::Normal;
7664
7665        app.handle_action(Action::StartFilter);
7666        assert_eq!(app.mode, Mode::FilterInput);
7667
7668        // Directly set filter (character input is handled in event loop, not actions)
7669        app.cfn_state.table.filter = "test".to_string();
7670        assert_eq!(app.cfn_state.table.filter, "test");
7671    }
7672
7673    #[test]
7674    fn test_cloudformation_filter_applies() {
7675        let mut app = test_app();
7676        app.current_service = Service::CloudFormationStacks;
7677        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7678        app.cfn_state.table.items = vec![
7679            CfnStack {
7680                name: "prod-stack".to_string(),
7681                stack_id: "id1".to_string(),
7682                status: "CREATE_COMPLETE".to_string(),
7683                created_time: "2024-01-01".to_string(),
7684                updated_time: String::new(),
7685                deleted_time: String::new(),
7686                drift_status: String::new(),
7687                last_drift_check_time: String::new(),
7688                status_reason: String::new(),
7689                description: "Production stack".to_string(),
7690                detailed_status: String::new(),
7691                root_stack: String::new(),
7692                parent_stack: String::new(),
7693                termination_protection: false,
7694                iam_role: String::new(),
7695                tags: Vec::new(),
7696                stack_policy: String::new(),
7697                rollback_monitoring_time: String::new(),
7698                rollback_alarms: Vec::new(),
7699                notification_arns: Vec::new(),
7700            },
7701            CfnStack {
7702                name: "dev-stack".to_string(),
7703                stack_id: "id2".to_string(),
7704                status: "UPDATE_COMPLETE".to_string(),
7705                created_time: "2024-01-02".to_string(),
7706                updated_time: String::new(),
7707                deleted_time: String::new(),
7708                drift_status: String::new(),
7709                last_drift_check_time: String::new(),
7710                status_reason: String::new(),
7711                description: "Development stack".to_string(),
7712                detailed_status: String::new(),
7713                root_stack: String::new(),
7714                parent_stack: String::new(),
7715                termination_protection: false,
7716                iam_role: String::new(),
7717                tags: Vec::new(),
7718                stack_policy: String::new(),
7719                rollback_monitoring_time: String::new(),
7720                rollback_alarms: Vec::new(),
7721                notification_arns: Vec::new(),
7722            },
7723        ];
7724        app.cfn_state.table.filter = "prod".to_string();
7725
7726        let filtered = filtered_cloudformation_stacks(&app);
7727        assert_eq!(filtered.len(), 1);
7728        assert_eq!(filtered[0].name, "prod-stack");
7729    }
7730
7731    #[test]
7732    fn test_cloudformation_right_arrow_expands() {
7733        let mut app = test_app();
7734        app.current_service = Service::CloudFormationStacks;
7735        app.service_selected = true;
7736        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7737        app.cfn_state.table.items = vec![CfnStack {
7738            name: "test-stack".to_string(),
7739            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
7740                .to_string(),
7741            status: "CREATE_COMPLETE".to_string(),
7742            created_time: "2024-01-01".to_string(),
7743            updated_time: String::new(),
7744            deleted_time: String::new(),
7745            drift_status: String::new(),
7746            last_drift_check_time: String::new(),
7747            status_reason: String::new(),
7748            description: "Test stack".to_string(),
7749            detailed_status: String::new(),
7750            root_stack: String::new(),
7751            parent_stack: String::new(),
7752            termination_protection: false,
7753            iam_role: String::new(),
7754            tags: Vec::new(),
7755            stack_policy: String::new(),
7756            rollback_monitoring_time: String::new(),
7757            rollback_alarms: Vec::new(),
7758            notification_arns: Vec::new(),
7759        }];
7760        app.cfn_state.table.reset();
7761
7762        assert_eq!(app.cfn_state.table.expanded_item, None);
7763
7764        app.handle_action(Action::NextPane);
7765        assert_eq!(app.cfn_state.table.expanded_item, Some(0));
7766    }
7767
7768    #[test]
7769    fn test_cloudformation_left_arrow_collapses() {
7770        let mut app = test_app();
7771        app.current_service = Service::CloudFormationStacks;
7772        app.service_selected = true;
7773        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7774        app.cfn_state.table.items = vec![CfnStack {
7775            name: "test-stack".to_string(),
7776            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
7777                .to_string(),
7778            status: "CREATE_COMPLETE".to_string(),
7779            created_time: "2024-01-01".to_string(),
7780            updated_time: String::new(),
7781            deleted_time: String::new(),
7782            drift_status: String::new(),
7783            last_drift_check_time: String::new(),
7784            status_reason: String::new(),
7785            description: "Test stack".to_string(),
7786            detailed_status: String::new(),
7787            root_stack: String::new(),
7788            parent_stack: String::new(),
7789            termination_protection: false,
7790            iam_role: String::new(),
7791            tags: Vec::new(),
7792            stack_policy: String::new(),
7793            rollback_monitoring_time: String::new(),
7794            rollback_alarms: Vec::new(),
7795            notification_arns: Vec::new(),
7796        }];
7797        app.cfn_state.table.reset();
7798        app.cfn_state.table.expanded_item = Some(0);
7799
7800        app.handle_action(Action::PrevPane);
7801        assert_eq!(app.cfn_state.table.expanded_item, None);
7802    }
7803
7804    #[test]
7805    fn test_cloudformation_enter_drills_into_stack() {
7806        let mut app = test_app();
7807        app.current_service = Service::CloudFormationStacks;
7808        app.service_selected = true;
7809        app.mode = Mode::Normal;
7810        app.tabs = vec![Tab {
7811            service: Service::CloudFormationStacks,
7812            title: "CloudFormation › Stacks".to_string(),
7813            breadcrumb: "CloudFormation › Stacks".to_string(),
7814        }];
7815        app.current_tab = 0;
7816        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7817        app.cfn_state.table.items = vec![CfnStack {
7818            name: "test-stack".to_string(),
7819            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
7820                .to_string(),
7821            status: "CREATE_COMPLETE".to_string(),
7822            created_time: "2024-01-01".to_string(),
7823            updated_time: String::new(),
7824            deleted_time: String::new(),
7825            drift_status: String::new(),
7826            last_drift_check_time: String::new(),
7827            status_reason: String::new(),
7828            description: "Test stack".to_string(),
7829            detailed_status: String::new(),
7830            root_stack: String::new(),
7831            parent_stack: String::new(),
7832            termination_protection: false,
7833            iam_role: String::new(),
7834            tags: Vec::new(),
7835            stack_policy: String::new(),
7836            rollback_monitoring_time: String::new(),
7837            rollback_alarms: Vec::new(),
7838            notification_arns: Vec::new(),
7839        }];
7840        app.cfn_state.table.reset();
7841
7842        // Verify filtering works
7843        let filtered = filtered_cloudformation_stacks(&app);
7844        assert_eq!(filtered.len(), 1);
7845        assert_eq!(filtered[0].name, "test-stack");
7846
7847        assert_eq!(app.cfn_state.current_stack, None);
7848
7849        // Enter drills into stack detail view
7850        app.handle_action(Action::Select);
7851        assert_eq!(app.cfn_state.current_stack, Some("test-stack".to_string()));
7852    }
7853
7854    #[test]
7855    fn test_cloudformation_copy_to_clipboard() {
7856        let mut app = test_app();
7857        app.current_service = Service::CloudFormationStacks;
7858        app.service_selected = true;
7859        app.mode = Mode::Normal;
7860        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7861        app.cfn_state.table.items = vec![
7862            CfnStack {
7863                name: "stack1".to_string(),
7864                stack_id: "id1".to_string(),
7865                status: "CREATE_COMPLETE".to_string(),
7866                created_time: "2024-01-01".to_string(),
7867                updated_time: String::new(),
7868                deleted_time: String::new(),
7869                drift_status: String::new(),
7870                last_drift_check_time: String::new(),
7871                status_reason: String::new(),
7872                description: String::new(),
7873                detailed_status: String::new(),
7874                root_stack: String::new(),
7875                parent_stack: String::new(),
7876                termination_protection: false,
7877                iam_role: String::new(),
7878                tags: Vec::new(),
7879                stack_policy: String::new(),
7880                rollback_monitoring_time: String::new(),
7881                rollback_alarms: Vec::new(),
7882                notification_arns: Vec::new(),
7883            },
7884            CfnStack {
7885                name: "stack2".to_string(),
7886                stack_id: "id2".to_string(),
7887                status: "UPDATE_COMPLETE".to_string(),
7888                created_time: "2024-01-02".to_string(),
7889                updated_time: String::new(),
7890                deleted_time: String::new(),
7891                drift_status: String::new(),
7892                last_drift_check_time: String::new(),
7893                status_reason: String::new(),
7894                description: String::new(),
7895                detailed_status: String::new(),
7896                root_stack: String::new(),
7897                parent_stack: String::new(),
7898                termination_protection: false,
7899                iam_role: String::new(),
7900                tags: Vec::new(),
7901                stack_policy: String::new(),
7902                rollback_monitoring_time: String::new(),
7903                rollback_alarms: Vec::new(),
7904                notification_arns: Vec::new(),
7905            },
7906        ];
7907
7908        assert!(!app.clipboard.snapshot_requested());
7909        app.handle_action(Action::CopyToClipboard);
7910
7911        // Should set snapshot_requested flag
7912        assert!(app.clipboard.snapshot_requested());
7913    }
7914
7915    #[test]
7916    fn test_cloudformation_expansion_shows_all_visible_columns() {
7917        let mut app = test_app();
7918        app.current_service = Service::CloudFormationStacks;
7919        app.cfn_state.status_filter = CfnStatusFilter::Complete;
7920        app.cfn_state.table.items = vec![CfnStack {
7921            name: "test-stack".to_string(),
7922            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
7923                .to_string(),
7924            status: "CREATE_COMPLETE".to_string(),
7925            created_time: "2024-01-01".to_string(),
7926            updated_time: "2024-01-02".to_string(),
7927            deleted_time: String::new(),
7928            drift_status: "IN_SYNC".to_string(),
7929            last_drift_check_time: "2024-01-03".to_string(),
7930            status_reason: String::new(),
7931            description: "Test description".to_string(),
7932            detailed_status: String::new(),
7933            root_stack: String::new(),
7934            parent_stack: String::new(),
7935            termination_protection: false,
7936            iam_role: String::new(),
7937            tags: Vec::new(),
7938            stack_policy: String::new(),
7939            rollback_monitoring_time: String::new(),
7940            rollback_alarms: Vec::new(),
7941            notification_arns: Vec::new(),
7942        }];
7943
7944        // Set visible columns
7945        app.cfn_visible_column_ids = [
7946            CfnColumn::Name,
7947            CfnColumn::Status,
7948            CfnColumn::CreatedTime,
7949            CfnColumn::Description,
7950        ]
7951        .iter()
7952        .map(|c| c.id())
7953        .collect();
7954
7955        app.cfn_state.table.expanded_item = Some(0);
7956
7957        // Verify all visible columns would be shown in expansion
7958        // (This is a structural test - actual rendering is in UI layer)
7959        assert_eq!(app.cfn_visible_column_ids.len(), 4);
7960        assert!(app.cfn_state.table.has_expanded_item());
7961    }
7962
7963    #[test]
7964    fn test_cloudformation_empty_list_shows_page_1() {
7965        let mut app = test_app();
7966        app.current_service = Service::CloudFormationStacks;
7967        app.cfn_state.table.items = vec![];
7968
7969        let filtered = filtered_cloudformation_stacks(&app);
7970        assert_eq!(filtered.len(), 0);
7971
7972        // Pagination should still show [1] even with 0 items
7973        let page_size = app.cfn_state.table.page_size.value();
7974        let total_pages = filtered.len().div_ceil(page_size);
7975        assert_eq!(total_pages, 0);
7976
7977        // render_pagination_text(0, 0) should return "[1]"
7978        // This is tested in UI layer
7979    }
7980}
7981
7982impl App {
7983    pub fn get_filtered_regions(&self) -> Vec<AwsRegion> {
7984        let mut all = AwsRegion::all();
7985
7986        // Add latencies to regions
7987        for region in &mut all {
7988            region.latency_ms = self.region_latencies.get(region.code).copied();
7989        }
7990
7991        // Filter by search term
7992        let filtered: Vec<AwsRegion> = if self.region_filter.is_empty() {
7993            all
7994        } else {
7995            let filter_lower = self.region_filter.to_lowercase();
7996            all.into_iter()
7997                .filter(|r| {
7998                    r.name.to_lowercase().contains(&filter_lower)
7999                        || r.code.to_lowercase().contains(&filter_lower)
8000                        || r.group.to_lowercase().contains(&filter_lower)
8001                })
8002                .collect()
8003        };
8004
8005        // Sort by latency (lowest first), treat None as 1000ms
8006        let mut sorted = filtered;
8007        sorted.sort_by_key(|r| r.latency_ms.unwrap_or(1000));
8008        sorted
8009    }
8010
8011    pub fn measure_region_latencies(&mut self) {
8012        use std::time::Instant;
8013        self.region_latencies.clear();
8014
8015        let regions = AwsRegion::all();
8016        let start_all = Instant::now();
8017        tracing::info!("Starting latency measurement for {} regions", regions.len());
8018
8019        let handles: Vec<_> = regions
8020            .iter()
8021            .map(|region| {
8022                let code = region.code.to_string();
8023                std::thread::spawn(move || {
8024                    // Use STS endpoint - fastest and most reliable
8025                    let endpoint = format!("https://sts.{}.amazonaws.com", code);
8026                    let start = Instant::now();
8027
8028                    match ureq::get(&endpoint)
8029                        .config()
8030                        .timeout_global(Some(std::time::Duration::from_secs(2)))
8031                        .build()
8032                        .call()
8033                    {
8034                        Ok(_) => {
8035                            let latency = start.elapsed().as_millis() as u64;
8036                            Some((code, latency))
8037                        }
8038                        Err(e) => {
8039                            tracing::debug!("Failed to measure {}: {}", code, e);
8040                            Some((code, 9999))
8041                        }
8042                    }
8043                })
8044            })
8045            .collect();
8046
8047        for handle in handles {
8048            if let Ok(Some((code, latency))) = handle.join() {
8049                self.region_latencies.insert(code, latency);
8050            }
8051        }
8052
8053        tracing::info!(
8054            "Measured {} regions in {:?}",
8055            self.region_latencies.len(),
8056            start_all.elapsed()
8057        );
8058    }
8059
8060    pub fn get_filtered_profiles(&self) -> Vec<&AwsProfile> {
8061        filter_profiles(&self.available_profiles, &self.profile_filter)
8062    }
8063
8064    pub fn get_filtered_sessions(&self) -> Vec<&Session> {
8065        if self.session_filter.is_empty() {
8066            return self.sessions.iter().collect();
8067        }
8068        let filter_lower = self.session_filter.to_lowercase();
8069        self.sessions
8070            .iter()
8071            .filter(|s| {
8072                s.profile.to_lowercase().contains(&filter_lower)
8073                    || s.region.to_lowercase().contains(&filter_lower)
8074                    || s.account_id.to_lowercase().contains(&filter_lower)
8075                    || s.role_arn.to_lowercase().contains(&filter_lower)
8076            })
8077            .collect()
8078    }
8079
8080    pub fn get_filtered_tabs(&self) -> Vec<(usize, &Tab)> {
8081        if self.tab_filter.is_empty() {
8082            return self.tabs.iter().enumerate().collect();
8083        }
8084        let filter_lower = self.tab_filter.to_lowercase();
8085        self.tabs
8086            .iter()
8087            .enumerate()
8088            .filter(|(_, tab)| {
8089                tab.title.to_lowercase().contains(&filter_lower)
8090                    || tab.breadcrumb.to_lowercase().contains(&filter_lower)
8091            })
8092            .collect()
8093    }
8094
8095    pub fn load_aws_profiles() -> Vec<AwsProfile> {
8096        AwsProfile::load_all()
8097    }
8098
8099    pub async fn fetch_profile_accounts(&mut self) {
8100        for profile in &mut self.available_profiles {
8101            if profile.account.is_none() {
8102                let region = profile
8103                    .region
8104                    .clone()
8105                    .unwrap_or_else(|| "us-east-1".to_string());
8106                if let Ok(account) =
8107                    rusticity_core::AwsConfig::get_account_for_profile(&profile.name, &region).await
8108                {
8109                    profile.account = Some(account);
8110                }
8111            }
8112        }
8113    }
8114
8115    fn save_current_session(&mut self) {
8116        // If no tabs, delete the session if it exists
8117        if self.tabs.is_empty() {
8118            if let Some(ref session) = self.current_session {
8119                let _ = session.delete();
8120                self.current_session = None;
8121            }
8122            return;
8123        }
8124
8125        let session = if let Some(ref mut current) = self.current_session {
8126            // Update existing session
8127            current.tabs = self
8128                .tabs
8129                .iter()
8130                .map(|t| SessionTab {
8131                    service: format!("{:?}", t.service),
8132                    title: t.title.clone(),
8133                    breadcrumb: t.breadcrumb.clone(),
8134                    filter: match t.service {
8135                        Service::CloudWatchLogGroups => {
8136                            Some(self.log_groups_state.log_groups.filter.clone())
8137                        }
8138                        _ => None,
8139                    },
8140                    selected_item: None,
8141                })
8142                .collect();
8143            current.clone()
8144        } else {
8145            // Create new session
8146            let mut session = Session::new(
8147                self.profile.clone(),
8148                self.region.clone(),
8149                self.config.account_id.clone(),
8150                self.config.role_arn.clone(),
8151            );
8152            session.tabs = self
8153                .tabs
8154                .iter()
8155                .map(|t| SessionTab {
8156                    service: format!("{:?}", t.service),
8157                    title: t.title.clone(),
8158                    breadcrumb: t.breadcrumb.clone(),
8159                    filter: match t.service {
8160                        Service::CloudWatchLogGroups => {
8161                            Some(self.log_groups_state.log_groups.filter.clone())
8162                        }
8163                        _ => None,
8164                    },
8165                    selected_item: None,
8166                })
8167                .collect();
8168            self.current_session = Some(session.clone());
8169            session
8170        };
8171
8172        let _ = session.save();
8173    }
8174}
8175
8176#[cfg(test)]
8177mod iam_policy_view_tests {
8178    use super::*;
8179    use test_helpers::*;
8180
8181    #[test]
8182    fn test_enter_opens_policy_view() {
8183        let mut app = test_app();
8184        app.current_service = Service::IamRoles;
8185        app.service_selected = true;
8186        app.mode = Mode::Normal;
8187        app.view_mode = ViewMode::Detail;
8188        app.iam_state.current_role = Some("TestRole".to_string());
8189        app.iam_state.policies.items = vec![IamPolicy {
8190            policy_name: "TestPolicy".to_string(),
8191            policy_type: "Inline".to_string(),
8192            attached_via: "Direct".to_string(),
8193            attached_entities: "1".to_string(),
8194            description: "Test".to_string(),
8195            creation_time: "2023-01-01".to_string(),
8196            edited_time: "2023-01-01".to_string(),
8197            policy_arn: None,
8198        }];
8199        app.iam_state.policies.reset();
8200
8201        app.handle_action(Action::Select);
8202
8203        assert_eq!(app.view_mode, ViewMode::PolicyView);
8204        assert_eq!(app.iam_state.current_policy, Some("TestPolicy".to_string()));
8205        assert_eq!(app.iam_state.policy_scroll, 0);
8206        assert!(app.iam_state.policies.loading);
8207    }
8208
8209    #[test]
8210    fn test_escape_closes_policy_view() {
8211        let mut app = test_app();
8212        app.current_service = Service::IamRoles;
8213        app.service_selected = true;
8214        app.mode = Mode::Normal;
8215        app.view_mode = ViewMode::PolicyView;
8216        app.iam_state.current_role = Some("TestRole".to_string());
8217        app.iam_state.current_policy = Some("TestPolicy".to_string());
8218        app.iam_state.policy_document = "{\n  \"test\": \"value\"\n}".to_string();
8219        app.iam_state.policy_scroll = 5;
8220
8221        app.handle_action(Action::PrevPane);
8222
8223        assert_eq!(app.view_mode, ViewMode::Detail);
8224        assert_eq!(app.iam_state.current_policy, None);
8225        assert_eq!(app.iam_state.policy_document, "");
8226        assert_eq!(app.iam_state.policy_scroll, 0);
8227    }
8228
8229    #[test]
8230    fn test_ctrl_d_scrolls_down_in_policy_view() {
8231        let mut app = test_app();
8232        app.current_service = Service::IamRoles;
8233        app.service_selected = true;
8234        app.mode = Mode::Normal;
8235        app.view_mode = ViewMode::PolicyView;
8236        app.iam_state.current_role = Some("TestRole".to_string());
8237        app.iam_state.current_policy = Some("TestPolicy".to_string());
8238        app.iam_state.policy_document = (0..100)
8239            .map(|i| format!("line {}", i))
8240            .collect::<Vec<_>>()
8241            .join("\n");
8242        app.iam_state.policy_scroll = 0;
8243
8244        app.handle_action(Action::ScrollDown);
8245
8246        assert_eq!(app.iam_state.policy_scroll, 10);
8247
8248        app.handle_action(Action::ScrollDown);
8249
8250        assert_eq!(app.iam_state.policy_scroll, 20);
8251    }
8252
8253    #[test]
8254    fn test_ctrl_u_scrolls_up_in_policy_view() {
8255        let mut app = test_app();
8256        app.current_service = Service::IamRoles;
8257        app.service_selected = true;
8258        app.mode = Mode::Normal;
8259        app.view_mode = ViewMode::PolicyView;
8260        app.iam_state.current_role = Some("TestRole".to_string());
8261        app.iam_state.current_policy = Some("TestPolicy".to_string());
8262        app.iam_state.policy_document = (0..100)
8263            .map(|i| format!("line {}", i))
8264            .collect::<Vec<_>>()
8265            .join("\n");
8266        app.iam_state.policy_scroll = 30;
8267
8268        app.handle_action(Action::ScrollUp);
8269
8270        assert_eq!(app.iam_state.policy_scroll, 20);
8271
8272        app.handle_action(Action::ScrollUp);
8273
8274        assert_eq!(app.iam_state.policy_scroll, 10);
8275    }
8276
8277    #[test]
8278    fn test_scroll_does_not_go_negative() {
8279        let mut app = test_app();
8280        app.current_service = Service::IamRoles;
8281        app.service_selected = true;
8282        app.mode = Mode::Normal;
8283        app.view_mode = ViewMode::PolicyView;
8284        app.iam_state.current_role = Some("TestRole".to_string());
8285        app.iam_state.current_policy = Some("TestPolicy".to_string());
8286        app.iam_state.policy_document = "line 1\nline 2\nline 3".to_string();
8287        app.iam_state.policy_scroll = 0;
8288
8289        app.handle_action(Action::ScrollUp);
8290
8291        assert_eq!(app.iam_state.policy_scroll, 0);
8292    }
8293
8294    #[test]
8295    fn test_scroll_does_not_exceed_max() {
8296        let mut app = test_app();
8297        app.current_service = Service::IamRoles;
8298        app.service_selected = true;
8299        app.mode = Mode::Normal;
8300        app.view_mode = ViewMode::PolicyView;
8301        app.iam_state.current_role = Some("TestRole".to_string());
8302        app.iam_state.current_policy = Some("TestPolicy".to_string());
8303        app.iam_state.policy_document = "line 1\nline 2\nline 3".to_string();
8304        app.iam_state.policy_scroll = 0;
8305
8306        app.handle_action(Action::ScrollDown);
8307
8308        assert_eq!(app.iam_state.policy_scroll, 2); // Max is 2 (3 lines - 1)
8309    }
8310
8311    #[test]
8312    fn test_policy_view_console_url() {
8313        let mut app = test_app();
8314        app.current_service = Service::IamRoles;
8315        app.service_selected = true;
8316        app.view_mode = ViewMode::PolicyView;
8317        app.iam_state.current_role = Some("TestRole".to_string());
8318        app.iam_state.current_policy = Some("TestPolicy".to_string());
8319
8320        let url = app.get_console_url();
8321
8322        assert!(url.contains("us-east-1.console.aws.amazon.com"));
8323        assert!(url.contains("/roles/details/TestRole"));
8324        assert!(url.contains("/editPolicy/TestPolicy"));
8325        assert!(url.contains("step=addPermissions"));
8326    }
8327
8328    #[test]
8329    fn test_esc_from_policy_view_goes_to_role_detail() {
8330        let mut app = test_app();
8331        app.current_service = Service::IamRoles;
8332        app.service_selected = true;
8333        app.mode = Mode::Normal;
8334        app.view_mode = ViewMode::PolicyView;
8335        app.iam_state.current_role = Some("TestRole".to_string());
8336        app.iam_state.current_policy = Some("TestPolicy".to_string());
8337        app.iam_state.policy_document = "test".to_string();
8338        app.iam_state.policy_scroll = 5;
8339
8340        app.handle_action(Action::GoBack);
8341
8342        assert_eq!(app.view_mode, ViewMode::Detail);
8343        assert_eq!(app.iam_state.current_policy, None);
8344        assert_eq!(app.iam_state.policy_document, "");
8345        assert_eq!(app.iam_state.policy_scroll, 0);
8346        assert_eq!(app.iam_state.current_role, Some("TestRole".to_string()));
8347    }
8348
8349    #[test]
8350    fn test_esc_from_role_detail_goes_to_role_list() {
8351        let mut app = test_app();
8352        app.current_service = Service::IamRoles;
8353        app.service_selected = true;
8354        app.mode = Mode::Normal;
8355        app.view_mode = ViewMode::Detail;
8356        app.iam_state.current_role = Some("TestRole".to_string());
8357
8358        app.handle_action(Action::GoBack);
8359
8360        assert_eq!(app.iam_state.current_role, None);
8361    }
8362
8363    #[test]
8364    fn test_right_arrow_expands_policy_row() {
8365        let mut app = test_app();
8366        app.current_service = Service::IamRoles;
8367        app.service_selected = true;
8368        app.mode = Mode::Normal;
8369        app.view_mode = ViewMode::Detail;
8370        app.iam_state.current_role = Some("TestRole".to_string());
8371        app.iam_state.policies.items = vec![IamPolicy {
8372            policy_name: "TestPolicy".to_string(),
8373            policy_type: "Inline".to_string(),
8374            attached_via: "Direct".to_string(),
8375            attached_entities: "1".to_string(),
8376            description: "Test".to_string(),
8377            creation_time: "2023-01-01".to_string(),
8378            edited_time: "2023-01-01".to_string(),
8379            policy_arn: None,
8380        }];
8381        app.iam_state.policies.reset();
8382
8383        app.handle_action(Action::NextPane);
8384
8385        // Should expand, not drill down
8386        assert_eq!(app.view_mode, ViewMode::Detail);
8387        assert_eq!(app.iam_state.current_policy, None);
8388        assert_eq!(app.iam_state.policies.expanded_item, Some(0));
8389    }
8390
8391    #[test]
8392    fn test_managed_policy_has_creation_and_edit_time() {
8393        // Managed policies have a policy_arn and should show creation/edit times
8394        // when loaded. Inline policies don't have these (they're embedded in the role).
8395        // This test verifies managed policies don't show "-" for creation_time.
8396        use crate::iam::Policy as IamPolicy;
8397        let managed = IamPolicy {
8398            policy_name: "MyManagedPolicy".to_string(),
8399            policy_type: "Managed".to_string(),
8400            attached_via: "Direct".to_string(),
8401            attached_entities: "-".to_string(),
8402            description: "-".to_string(),
8403            creation_time: "2024-01-01 00:00:00 (UTC)".to_string(),
8404            edited_time: "2024-06-01 00:00:00 (UTC)".to_string(),
8405            policy_arn: Some("arn:aws:iam::123:policy/MyManagedPolicy".to_string()),
8406        };
8407        assert_ne!(
8408            managed.creation_time, "-",
8409            "Managed policy must have a real creation time, not '-'"
8410        );
8411        assert_ne!(
8412            managed.edited_time, "-",
8413            "Managed policy must have a real edit time, not '-'"
8414        );
8415
8416        // Inline policies legitimately have no creation/edit time
8417        let inline = IamPolicy {
8418            policy_name: "InlinePolicy".to_string(),
8419            policy_type: "Inline".to_string(),
8420            attached_via: "Direct".to_string(),
8421            attached_entities: "-".to_string(),
8422            description: "-".to_string(),
8423            creation_time: "-".to_string(),
8424            edited_time: "-".to_string(),
8425            policy_arn: None,
8426        };
8427        assert_eq!(
8428            inline.creation_time, "-",
8429            "Inline policies have no creation time"
8430        );
8431    }
8432
8433    #[test]
8434    fn test_yank_in_policy_view_copies_document_not_arn() {
8435        // In PolicyView, 'y' must copy the policy JSON, not the role ARN.
8436        let mut app = test_app();
8437        app.current_service = Service::IamRoles;
8438        app.service_selected = true;
8439        app.mode = Mode::Normal;
8440        app.view_mode = ViewMode::PolicyView;
8441        app.iam_state.current_role = Some("TestRole".to_string());
8442        app.iam_state.current_policy = Some("TestPolicy".to_string());
8443
8444        let policy_json = "{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": []\n}".to_string();
8445        app.iam_state.policy_document = policy_json.clone();
8446
8447        // Call yank_roles — it must read policy_document when view_mode == PolicyView.
8448        use crate::iam::actions::yank_roles;
8449        yank_roles(&app);
8450
8451        // State must be unchanged after yank (clipboard is a side-effect we can't inspect in tests)
8452        assert_eq!(app.iam_state.policy_document, policy_json);
8453        assert_eq!(app.view_mode, ViewMode::PolicyView);
8454    }
8455}
8456
8457#[cfg(test)]
8458mod tab_filter_tests {
8459    use super::*;
8460    use test_helpers::*;
8461
8462    #[test]
8463    fn test_space_t_opens_tab_picker() {
8464        let mut app = test_app();
8465        app.tabs = vec![
8466            Tab {
8467                service: Service::CloudWatchLogGroups,
8468                title: "Tab 1".to_string(),
8469                breadcrumb: "CloudWatch > Log groups".to_string(),
8470            },
8471            Tab {
8472                service: Service::S3Buckets,
8473                title: "Tab 2".to_string(),
8474                breadcrumb: "S3 › Buckets".to_string(),
8475            },
8476        ];
8477        app.current_tab = 0;
8478
8479        app.handle_action(Action::OpenTabPicker);
8480
8481        assert_eq!(app.mode, Mode::TabPicker);
8482        assert_eq!(app.tab_picker_selected, 0);
8483    }
8484
8485    #[test]
8486    fn test_tab_filter_works() {
8487        let mut app = test_app();
8488        app.tabs = vec![
8489            Tab {
8490                service: Service::CloudWatchLogGroups,
8491                title: "CloudWatch Logs".to_string(),
8492                breadcrumb: "CloudWatch > Log groups".to_string(),
8493            },
8494            Tab {
8495                service: Service::S3Buckets,
8496                title: "S3 Buckets".to_string(),
8497                breadcrumb: "S3 › Buckets".to_string(),
8498            },
8499            Tab {
8500                service: Service::CloudWatchAlarms,
8501                title: "CloudWatch Alarms".to_string(),
8502                breadcrumb: "CloudWatch › Alarms".to_string(),
8503            },
8504        ];
8505        app.mode = Mode::TabPicker;
8506
8507        // Filter for "s3"
8508        app.handle_action(Action::FilterInput('s'));
8509        app.handle_action(Action::FilterInput('3'));
8510
8511        let filtered = app.get_filtered_tabs();
8512        assert_eq!(filtered.len(), 1);
8513        assert_eq!(filtered[0].1.title, "S3 Buckets");
8514    }
8515
8516    #[test]
8517    fn test_tab_filter_by_breadcrumb() {
8518        let mut app = test_app();
8519        app.tabs = vec![
8520            Tab {
8521                service: Service::CloudWatchLogGroups,
8522                title: "Tab 1".to_string(),
8523                breadcrumb: "CloudWatch > Log groups".to_string(),
8524            },
8525            Tab {
8526                service: Service::S3Buckets,
8527                title: "Tab 2".to_string(),
8528                breadcrumb: "S3 › Buckets".to_string(),
8529            },
8530        ];
8531        app.mode = Mode::TabPicker;
8532
8533        // Filter for "cloudwatch"
8534        app.handle_action(Action::FilterInput('c'));
8535        app.handle_action(Action::FilterInput('l'));
8536        app.handle_action(Action::FilterInput('o'));
8537        app.handle_action(Action::FilterInput('u'));
8538        app.handle_action(Action::FilterInput('d'));
8539
8540        let filtered = app.get_filtered_tabs();
8541        assert_eq!(filtered.len(), 1);
8542        assert_eq!(filtered[0].1.breadcrumb, "CloudWatch > Log groups");
8543    }
8544
8545    #[test]
8546    fn test_tab_filter_backspace() {
8547        let mut app = test_app();
8548        app.tabs = vec![
8549            Tab {
8550                service: Service::CloudWatchLogGroups,
8551                title: "CloudWatch Logs".to_string(),
8552                breadcrumb: "CloudWatch > Log groups".to_string(),
8553            },
8554            Tab {
8555                service: Service::S3Buckets,
8556                title: "S3 Buckets".to_string(),
8557                breadcrumb: "S3 › Buckets".to_string(),
8558            },
8559        ];
8560        app.mode = Mode::TabPicker;
8561
8562        app.handle_action(Action::FilterInput('s'));
8563        app.handle_action(Action::FilterInput('3'));
8564        assert_eq!(app.tab_filter, "s3");
8565
8566        app.handle_action(Action::FilterBackspace);
8567        assert_eq!(app.tab_filter, "s");
8568
8569        let filtered = app.get_filtered_tabs();
8570        assert_eq!(filtered.len(), 2); // Both match "s"
8571    }
8572
8573    #[test]
8574    fn test_tab_selection_with_filter() {
8575        let mut app = test_app();
8576        app.tabs = vec![
8577            Tab {
8578                service: Service::CloudWatchLogGroups,
8579                title: "CloudWatch Logs".to_string(),
8580                breadcrumb: "CloudWatch > Log groups".to_string(),
8581            },
8582            Tab {
8583                service: Service::S3Buckets,
8584                title: "S3 Buckets".to_string(),
8585                breadcrumb: "S3 › Buckets".to_string(),
8586            },
8587        ];
8588        app.mode = Mode::TabPicker;
8589        app.current_tab = 0;
8590
8591        // Filter for "s3"
8592        app.handle_action(Action::FilterInput('s'));
8593        app.handle_action(Action::FilterInput('3'));
8594
8595        // Select the filtered tab
8596        app.handle_action(Action::Select);
8597
8598        assert_eq!(app.current_tab, 1); // Should select the S3 tab (index 1)
8599        assert_eq!(app.mode, Mode::Normal);
8600        assert_eq!(app.tab_filter, ""); // Filter should be cleared
8601    }
8602}
8603
8604#[cfg(test)]
8605mod region_latency_tests {
8606    use super::*;
8607    use test_helpers::*;
8608
8609    #[test]
8610    fn test_regions_sorted_by_latency() {
8611        let mut app = test_app();
8612
8613        // Add some latencies
8614        app.region_latencies.insert("us-west-2".to_string(), 50);
8615        app.region_latencies.insert("us-east-1".to_string(), 10);
8616        app.region_latencies.insert("eu-west-1".to_string(), 100);
8617
8618        let filtered = app.get_filtered_regions();
8619
8620        // Should be sorted by latency (lowest first)
8621        let with_latency: Vec<_> = filtered.iter().filter(|r| r.latency_ms.is_some()).collect();
8622
8623        assert!(with_latency.len() >= 3);
8624        assert_eq!(with_latency[0].code, "us-east-1");
8625        assert_eq!(with_latency[0].latency_ms, Some(10));
8626        assert_eq!(with_latency[1].code, "us-west-2");
8627        assert_eq!(with_latency[1].latency_ms, Some(50));
8628        assert_eq!(with_latency[2].code, "eu-west-1");
8629        assert_eq!(with_latency[2].latency_ms, Some(100));
8630    }
8631
8632    #[test]
8633    fn test_regions_with_latency_before_without() {
8634        let mut app = test_app();
8635
8636        // Only add latency for one region
8637        app.region_latencies.insert("eu-west-1".to_string(), 100);
8638
8639        let filtered = app.get_filtered_regions();
8640
8641        // Region with latency should come first
8642        assert_eq!(filtered[0].code, "eu-west-1");
8643        assert_eq!(filtered[0].latency_ms, Some(100));
8644
8645        // Rest should be sorted by name
8646        for region in &filtered[1..] {
8647            assert!(region.latency_ms.is_none());
8648        }
8649    }
8650
8651    #[test]
8652    fn test_region_filter_with_latency() {
8653        let mut app = test_app();
8654
8655        app.region_latencies.insert("us-east-1".to_string(), 10);
8656        app.region_latencies.insert("us-west-2".to_string(), 50);
8657        app.region_filter = "us".to_string();
8658
8659        let filtered = app.get_filtered_regions();
8660
8661        // Should only have US regions, sorted by latency
8662        assert!(filtered.iter().all(|r| r.code.starts_with("us-")));
8663        assert_eq!(filtered[0].code, "us-east-1");
8664        assert_eq!(filtered[1].code, "us-west-2");
8665    }
8666
8667    #[test]
8668    fn test_latency_persists_across_filters() {
8669        let mut app = test_app();
8670
8671        app.region_latencies.insert("us-east-1".to_string(), 10);
8672
8673        // Filter to something else
8674        app.region_filter = "eu".to_string();
8675        let filtered = app.get_filtered_regions();
8676        assert!(filtered.iter().all(|r| !r.code.starts_with("us-")));
8677
8678        // Clear filter
8679        app.region_filter.clear();
8680        let all = app.get_filtered_regions();
8681
8682        // Latency should still be there
8683        let us_east = all.iter().find(|r| r.code == "us-east-1").unwrap();
8684        assert_eq!(us_east.latency_ms, Some(10));
8685    }
8686
8687    #[test]
8688    fn test_measure_region_latencies_clears_previous() {
8689        let mut app = test_app();
8690
8691        // Add some fake latencies
8692        app.region_latencies.insert("us-east-1".to_string(), 100);
8693        app.region_latencies.insert("eu-west-1".to_string(), 200);
8694
8695        // Measure again (will fail to connect but should clear)
8696        app.measure_region_latencies();
8697
8698        // Old latencies should be cleared
8699        assert!(
8700            app.region_latencies.is_empty() || !app.region_latencies.contains_key("fake-region")
8701        );
8702    }
8703
8704    #[test]
8705    fn test_regions_with_latency_sorted_first() {
8706        let mut app = test_app();
8707
8708        // Add latencies: one fast, one slow (>1000ms would be treated as >1s)
8709        app.region_latencies.insert("us-east-1".to_string(), 50);
8710        app.region_latencies.insert("eu-west-1".to_string(), 500);
8711
8712        let filtered = app.get_filtered_regions();
8713
8714        // Should show all regions
8715        assert!(filtered.len() > 2);
8716
8717        // Fast regions first
8718        assert_eq!(filtered[0].code, "us-east-1");
8719        assert_eq!(filtered[0].latency_ms, Some(50));
8720        assert_eq!(filtered[1].code, "eu-west-1");
8721        assert_eq!(filtered[1].latency_ms, Some(500));
8722
8723        // Regions without latency treated as 1000ms, so they come after 500ms
8724        for region in &filtered[2..] {
8725            assert!(region.latency_ms.is_none());
8726        }
8727    }
8728
8729    #[test]
8730    fn test_regions_without_latency_sorted_as_1000ms() {
8731        let mut app = test_app();
8732
8733        // Add one region with 1500ms (slower than default 1000ms)
8734        app.region_latencies
8735            .insert("ap-southeast-2".to_string(), 1500);
8736        // Add one region with 50ms (faster)
8737        app.region_latencies.insert("us-east-1".to_string(), 50);
8738
8739        let filtered = app.get_filtered_regions();
8740
8741        // Fast region first
8742        assert_eq!(filtered[0].code, "us-east-1");
8743        assert_eq!(filtered[0].latency_ms, Some(50));
8744
8745        // Regions without latency (treated as 1000ms) come before 1500ms
8746        let slow_region_idx = filtered
8747            .iter()
8748            .position(|r| r.code == "ap-southeast-2")
8749            .unwrap();
8750        assert!(slow_region_idx > 1); // Should be after fast region and regions without latency
8751
8752        // All regions between index 1 and slow_region_idx should have no latency
8753        for region in filtered.iter().take(slow_region_idx).skip(1) {
8754            assert!(region.latency_ms.is_none());
8755        }
8756    }
8757
8758    #[test]
8759    fn test_region_picker_opens_with_latencies() {
8760        let mut app = test_app();
8761
8762        // Simulate opening region picker
8763        app.region_filter.clear();
8764        app.region_picker_selected = 0;
8765        app.measure_region_latencies();
8766
8767        // Should have attempted to measure (even if all fail in test env)
8768        // The map should be initialized
8769        assert!(app.region_latencies.is_empty() || !app.region_latencies.is_empty());
8770    }
8771
8772    #[test]
8773    fn test_region_picker_enter_in_filter_mode_exits_insert_not_selects() {
8774        // Regression: pressing Enter while filter is active (INSERT mode) must
8775        // exit INSERT mode and move focus to the table — NOT select the first region.
8776        // This matches ServicePicker behavior.
8777        let mut app = test_app();
8778        app.mode = Mode::RegionPicker;
8779        app.region_filter_active = true; // INSERT mode — typing in filter
8780        app.region_filter = "us".to_string();
8781        let original_region = app.region.clone();
8782
8783        // Enter must exit INSERT mode, not select
8784        app.handle_action(Action::Select);
8785
8786        assert!(
8787            !app.region_filter_active,
8788            "Filter must be deactivated (switch to NORMAL/table focus)"
8789        );
8790        assert_eq!(
8791            app.mode,
8792            Mode::RegionPicker,
8793            "Must stay in RegionPicker, not close it"
8794        );
8795        assert_eq!(
8796            app.region, original_region,
8797            "Region must NOT change when Enter is pressed in INSERT mode"
8798        );
8799    }
8800
8801    #[test]
8802    fn test_region_picker_enter_in_normal_mode_selects_region() {
8803        // Second Enter (when filter is NOT active) must select the region.
8804        let mut app = test_app();
8805        app.mode = Mode::RegionPicker;
8806        app.region_filter_active = false; // NORMAL mode — table focused
8807        app.region_filter.clear();
8808
8809        app.handle_action(Action::Select);
8810
8811        // Should have selected and closed picker (mode changed)
8812        assert_eq!(
8813            app.mode,
8814            Mode::Normal,
8815            "Must close picker after selecting region in NORMAL mode"
8816        );
8817    }
8818
8819    #[test]
8820    fn test_region_filter_can_type_any_character_in_insert_mode() {
8821        // Regression: 'k' and 'j' couldn't be typed in the region filter because the
8822        // keymap maps them to PrevItem/NextItem in RegionPicker mode, but they should
8823        // go to the filter when region_filter_active is true.
8824        use crate::keymap::{handle_key, Mode as KMode};
8825        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8826
8827        let mut app = test_app();
8828        app.mode = Mode::RegionPicker;
8829        app.region_filter_active = true;
8830        app.region_filter.clear();
8831
8832        // Simulate actual keypresses through keymap
8833        let t_key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE);
8834        let o_key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
8835        let k_key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
8836
8837        // These go through keymap → handle_action
8838        if let Some(action) = handle_key(t_key, KMode::RegionPicker) {
8839            app.handle_action(action);
8840        }
8841        if let Some(action) = handle_key(o_key, KMode::RegionPicker) {
8842            app.handle_action(action);
8843        }
8844        if let Some(action) = handle_key(k_key, KMode::RegionPicker) {
8845            app.handle_action(action);
8846        }
8847
8848        assert_eq!(
8849            app.region_filter, "tok",
8850            "'k' must be typed into filter (not navigate up) when filter is active"
8851        );
8852    }
8853
8854    #[test]
8855    fn test_ecr_tab_next() {
8856        assert_eq!(EcrTab::Private.next(), EcrTab::Public);
8857        assert_eq!(EcrTab::Public.next(), EcrTab::Private);
8858    }
8859
8860    #[test]
8861    fn test_ecr_tab_switching() {
8862        let mut app = test_app();
8863        app.current_service = Service::EcrRepositories;
8864        app.service_selected = true;
8865        app.ecr_state.tab = EcrTab::Private;
8866
8867        app.handle_action(Action::NextDetailTab);
8868        assert_eq!(app.ecr_state.tab, EcrTab::Public);
8869        assert_eq!(app.ecr_state.repositories.selected, 0);
8870
8871        app.handle_action(Action::NextDetailTab);
8872        assert_eq!(app.ecr_state.tab, EcrTab::Private);
8873    }
8874
8875    #[test]
8876    fn test_ecr_navigation() {
8877        let mut app = test_app();
8878        app.current_service = Service::EcrRepositories;
8879        app.service_selected = true;
8880        app.mode = Mode::Normal;
8881        app.ecr_state.repositories.items = vec![
8882            EcrRepository {
8883                name: "repo1".to_string(),
8884                uri: "uri1".to_string(),
8885                created_at: "2023-01-01".to_string(),
8886                tag_immutability: "MUTABLE".to_string(),
8887                encryption_type: "AES256".to_string(),
8888            },
8889            EcrRepository {
8890                name: "repo2".to_string(),
8891                uri: "uri2".to_string(),
8892                created_at: "2023-01-02".to_string(),
8893                tag_immutability: "IMMUTABLE".to_string(),
8894                encryption_type: "KMS".to_string(),
8895            },
8896        ];
8897
8898        app.handle_action(Action::NextItem);
8899        assert_eq!(app.ecr_state.repositories.selected, 1);
8900
8901        app.handle_action(Action::PrevItem);
8902        assert_eq!(app.ecr_state.repositories.selected, 0);
8903    }
8904
8905    #[test]
8906    fn test_ecr_filter() {
8907        let mut app = test_app();
8908        app.current_service = Service::EcrRepositories;
8909        app.service_selected = true;
8910        app.ecr_state.repositories.items = vec![
8911            EcrRepository {
8912                name: "my-app".to_string(),
8913                uri: "uri1".to_string(),
8914                created_at: "2023-01-01".to_string(),
8915                tag_immutability: "MUTABLE".to_string(),
8916                encryption_type: "AES256".to_string(),
8917            },
8918            EcrRepository {
8919                name: "other-service".to_string(),
8920                uri: "uri2".to_string(),
8921                created_at: "2023-01-02".to_string(),
8922                tag_immutability: "IMMUTABLE".to_string(),
8923                encryption_type: "KMS".to_string(),
8924            },
8925        ];
8926
8927        app.ecr_state.repositories.filter = "app".to_string();
8928        let filtered = filtered_ecr_repositories(&app);
8929        assert_eq!(filtered.len(), 1);
8930        assert_eq!(filtered[0].name, "my-app");
8931    }
8932
8933    #[test]
8934    fn test_ecr_filter_input() {
8935        let mut app = test_app();
8936        app.current_service = Service::EcrRepositories;
8937        app.service_selected = true;
8938        app.mode = Mode::FilterInput;
8939
8940        app.handle_action(Action::FilterInput('t'));
8941        app.handle_action(Action::FilterInput('e'));
8942        app.handle_action(Action::FilterInput('s'));
8943        app.handle_action(Action::FilterInput('t'));
8944        assert_eq!(app.ecr_state.repositories.filter, "test");
8945
8946        app.handle_action(Action::FilterBackspace);
8947        assert_eq!(app.ecr_state.repositories.filter, "tes");
8948    }
8949
8950    #[test]
8951    fn test_ecr_filter_resets_selection() {
8952        let mut app = test_app();
8953        app.current_service = Service::EcrRepositories;
8954        app.service_selected = true;
8955        app.mode = Mode::FilterInput;
8956        app.ecr_state.repositories.items = vec![
8957            EcrRepository {
8958                name: "repo1".to_string(),
8959                uri: "uri1".to_string(),
8960                created_at: "2023-01-01".to_string(),
8961                tag_immutability: "MUTABLE".to_string(),
8962                encryption_type: "AES256".to_string(),
8963            },
8964            EcrRepository {
8965                name: "repo2".to_string(),
8966                uri: "uri2".to_string(),
8967                created_at: "2023-01-02".to_string(),
8968                tag_immutability: "IMMUTABLE".to_string(),
8969                encryption_type: "KMS".to_string(),
8970            },
8971            EcrRepository {
8972                name: "repo3".to_string(),
8973                uri: "uri3".to_string(),
8974                created_at: "2023-01-03".to_string(),
8975                tag_immutability: "MUTABLE".to_string(),
8976                encryption_type: "AES256".to_string(),
8977            },
8978        ];
8979
8980        // Move selection to second item
8981        app.ecr_state.repositories.selected = 2;
8982        assert_eq!(app.ecr_state.repositories.selected, 2);
8983
8984        // Apply filter - selection should reset to 0
8985        app.handle_action(Action::FilterInput('t'));
8986        assert_eq!(app.ecr_state.repositories.filter, "t");
8987        assert_eq!(app.ecr_state.repositories.selected, 0);
8988
8989        // Move selection again
8990        app.ecr_state.repositories.selected = 1;
8991
8992        // Backspace filter - selection should reset to 0
8993        app.handle_action(Action::FilterBackspace);
8994        assert_eq!(app.ecr_state.repositories.filter, "");
8995        assert_eq!(app.ecr_state.repositories.selected, 0);
8996    }
8997
8998    #[test]
8999    fn test_ecr_images_filter_resets_selection() {
9000        let mut app = test_app();
9001        app.current_service = Service::EcrRepositories;
9002        app.service_selected = true;
9003        app.mode = Mode::FilterInput;
9004        app.ecr_state.current_repository = Some("test-repo".to_string());
9005        app.ecr_state.images.items = vec![
9006            EcrImage {
9007                tag: "v1.0.0".to_string(),
9008                artifact_type: "container".to_string(),
9009                digest: "sha256:abc123".to_string(),
9010                pushed_at: "2023-01-01".to_string(),
9011                size_bytes: 1000,
9012                uri: "uri1".to_string(),
9013                last_pull_time: "".to_string(),
9014            },
9015            EcrImage {
9016                tag: "v2.0.0".to_string(),
9017                artifact_type: "container".to_string(),
9018                digest: "sha256:def456".to_string(),
9019                pushed_at: "2023-01-02".to_string(),
9020                size_bytes: 2000,
9021                uri: "uri2".to_string(),
9022                last_pull_time: "".to_string(),
9023            },
9024        ];
9025
9026        // Move selection to second item
9027        app.ecr_state.images.selected = 1;
9028        assert_eq!(app.ecr_state.images.selected, 1);
9029
9030        // Apply filter - selection should reset to 0
9031        app.handle_action(Action::FilterInput('v'));
9032        assert_eq!(app.ecr_state.images.filter, "v");
9033        assert_eq!(app.ecr_state.images.selected, 0);
9034    }
9035
9036    #[test]
9037    fn test_iam_users_filter_input() {
9038        let mut app = test_app();
9039        app.current_service = Service::IamUsers;
9040        app.service_selected = true;
9041        app.mode = Mode::FilterInput;
9042
9043        app.handle_action(Action::FilterInput('a'));
9044        app.handle_action(Action::FilterInput('d'));
9045        app.handle_action(Action::FilterInput('m'));
9046        app.handle_action(Action::FilterInput('i'));
9047        app.handle_action(Action::FilterInput('n'));
9048        assert_eq!(app.iam_state.users.filter, "admin");
9049
9050        app.handle_action(Action::FilterBackspace);
9051        assert_eq!(app.iam_state.users.filter, "admi");
9052    }
9053
9054    #[test]
9055    fn test_iam_policies_filter_input() {
9056        let mut app = test_app();
9057        app.current_service = Service::IamUsers;
9058        app.service_selected = true;
9059        app.iam_state.current_user = Some("testuser".to_string());
9060        app.mode = Mode::FilterInput;
9061
9062        app.handle_action(Action::FilterInput('r'));
9063        app.handle_action(Action::FilterInput('e'));
9064        app.handle_action(Action::FilterInput('a'));
9065        app.handle_action(Action::FilterInput('d'));
9066        assert_eq!(app.iam_state.policies.filter, "read");
9067
9068        app.handle_action(Action::FilterBackspace);
9069        assert_eq!(app.iam_state.policies.filter, "rea");
9070    }
9071
9072    #[test]
9073    fn test_iam_start_filter() {
9074        let mut app = test_app();
9075        app.current_service = Service::IamUsers;
9076        app.service_selected = true;
9077        app.mode = Mode::Normal;
9078
9079        app.handle_action(Action::StartFilter);
9080        assert_eq!(app.mode, Mode::FilterInput);
9081    }
9082
9083    #[test]
9084    fn test_iam_roles_filter_input() {
9085        let mut app = test_app();
9086        app.current_service = Service::IamRoles;
9087        app.service_selected = true;
9088        app.mode = Mode::FilterInput;
9089
9090        app.handle_action(Action::FilterInput('a'));
9091        app.handle_action(Action::FilterInput('d'));
9092        app.handle_action(Action::FilterInput('m'));
9093        app.handle_action(Action::FilterInput('i'));
9094        app.handle_action(Action::FilterInput('n'));
9095        assert_eq!(app.iam_state.roles.filter, "admin");
9096
9097        app.handle_action(Action::FilterBackspace);
9098        assert_eq!(app.iam_state.roles.filter, "admi");
9099    }
9100
9101    #[test]
9102    fn test_iam_roles_start_filter() {
9103        let mut app = test_app();
9104        app.current_service = Service::IamRoles;
9105        app.service_selected = true;
9106        app.mode = Mode::Normal;
9107
9108        app.handle_action(Action::StartFilter);
9109        assert_eq!(app.mode, Mode::FilterInput);
9110    }
9111
9112    #[test]
9113    fn test_iam_roles_navigation() {
9114        let mut app = test_app();
9115        app.current_service = Service::IamRoles;
9116        app.service_selected = true;
9117        app.mode = Mode::Normal;
9118        app.iam_state.roles.items = (0..10)
9119            .map(|i| IamRole {
9120                role_name: format!("role{}", i),
9121                path: "/".to_string(),
9122                trusted_entities: String::new(),
9123                last_activity: String::new(),
9124                arn: format!("arn:aws:iam::123456789012:role/role{}", i),
9125                creation_time: "2025-01-01 00:00:00 (UTC)".to_string(),
9126                description: String::new(),
9127                max_session_duration: Some(3600),
9128            })
9129            .collect();
9130
9131        assert_eq!(app.iam_state.roles.selected, 0);
9132
9133        app.handle_action(Action::NextItem);
9134        assert_eq!(app.iam_state.roles.selected, 1);
9135
9136        app.handle_action(Action::NextItem);
9137        assert_eq!(app.iam_state.roles.selected, 2);
9138
9139        app.handle_action(Action::PrevItem);
9140        assert_eq!(app.iam_state.roles.selected, 1);
9141    }
9142
9143    #[test]
9144    fn test_iam_roles_page_hotkey() {
9145        let mut app = test_app();
9146        app.current_service = Service::IamRoles;
9147        app.service_selected = true;
9148        app.mode = Mode::Normal;
9149        app.iam_state.roles.page_size = PageSize::Ten;
9150        app.iam_state.roles.items = (0..100)
9151            .map(|i| IamRole {
9152                role_name: format!("role{}", i),
9153                path: "/".to_string(),
9154                trusted_entities: String::new(),
9155                last_activity: String::new(),
9156                arn: format!("arn:aws:iam::123456789012:role/role{}", i),
9157                creation_time: "2025-01-01 00:00:00 (UTC)".to_string(),
9158                description: String::new(),
9159                max_session_duration: Some(3600),
9160            })
9161            .collect();
9162
9163        app.handle_action(Action::FilterInput('2'));
9164        app.handle_action(Action::OpenColumnSelector);
9165        assert_eq!(app.iam_state.roles.selected, 10); // Page 2 = index 10 (with page size 10)
9166    }
9167
9168    #[test]
9169    fn test_iam_users_page_hotkey() {
9170        let mut app = test_app();
9171        app.current_service = Service::IamUsers;
9172        app.service_selected = true;
9173        app.mode = Mode::Normal;
9174        app.iam_state.users.page_size = PageSize::Ten;
9175        app.iam_state.users.items = (0..100)
9176            .map(|i| IamUser {
9177                user_name: format!("user{}", i),
9178                path: "/".to_string(),
9179                groups: String::new(),
9180                last_activity: String::new(),
9181                mfa: String::new(),
9182                password_age: String::new(),
9183                console_last_sign_in: String::new(),
9184                access_key_id: String::new(),
9185                active_key_age: String::new(),
9186                access_key_last_used: String::new(),
9187                arn: format!("arn:aws:iam::123456789012:user/user{}", i),
9188                creation_time: "2025-01-01 00:00:00 (UTC)".to_string(),
9189                console_access: String::new(),
9190                signing_certs: String::new(),
9191            })
9192            .collect();
9193
9194        app.handle_action(Action::FilterInput('3'));
9195        app.handle_action(Action::OpenColumnSelector);
9196        assert_eq!(app.iam_state.users.selected, 20); // Page 3 = index 20 (with page size 10)
9197    }
9198
9199    #[test]
9200    fn test_ecr_scroll_navigation() {
9201        let mut app = test_app();
9202        app.current_service = Service::EcrRepositories;
9203        app.service_selected = true;
9204        app.ecr_state.repositories.items = (0..20)
9205            .map(|i| EcrRepository {
9206                name: format!("repo{}", i),
9207                uri: format!("uri{}", i),
9208                created_at: "2023-01-01".to_string(),
9209                tag_immutability: "MUTABLE".to_string(),
9210                encryption_type: "AES256".to_string(),
9211            })
9212            .collect();
9213
9214        app.handle_action(Action::ScrollDown);
9215        assert_eq!(app.ecr_state.repositories.selected, 10);
9216
9217        app.handle_action(Action::ScrollUp);
9218        assert_eq!(app.ecr_state.repositories.selected, 0);
9219    }
9220
9221    #[test]
9222    fn test_ecr_tab_switching_triggers_reload() {
9223        let mut app = test_app();
9224        app.current_service = Service::EcrRepositories;
9225        app.service_selected = true;
9226        app.ecr_state.tab = EcrTab::Private;
9227        app.ecr_state.repositories.loading = false;
9228        app.ecr_state.repositories.items = vec![EcrRepository {
9229            name: "private-repo".to_string(),
9230            uri: "uri".to_string(),
9231            created_at: "2023-01-01".to_string(),
9232            tag_immutability: "MUTABLE".to_string(),
9233            encryption_type: "AES256".to_string(),
9234        }];
9235
9236        app.handle_action(Action::NextDetailTab);
9237        assert_eq!(app.ecr_state.tab, EcrTab::Public);
9238        assert!(app.ecr_state.repositories.loading);
9239        assert_eq!(app.ecr_state.repositories.selected, 0);
9240    }
9241
9242    #[test]
9243    fn test_ecr_tab_cycles_between_private_and_public() {
9244        let mut app = test_app();
9245        app.current_service = Service::EcrRepositories;
9246        app.service_selected = true;
9247        app.ecr_state.tab = EcrTab::Private;
9248
9249        app.handle_action(Action::NextDetailTab);
9250        assert_eq!(app.ecr_state.tab, EcrTab::Public);
9251
9252        app.handle_action(Action::NextDetailTab);
9253        assert_eq!(app.ecr_state.tab, EcrTab::Private);
9254    }
9255
9256    #[test]
9257    fn test_page_size_values() {
9258        assert_eq!(PageSize::Ten.value(), 10);
9259        assert_eq!(PageSize::TwentyFive.value(), 25);
9260        assert_eq!(PageSize::Fifty.value(), 50);
9261        assert_eq!(PageSize::OneHundred.value(), 100);
9262    }
9263
9264    #[test]
9265    fn test_page_size_next() {
9266        assert_eq!(PageSize::Ten.next(), PageSize::TwentyFive);
9267        assert_eq!(PageSize::TwentyFive.next(), PageSize::Fifty);
9268        assert_eq!(PageSize::Fifty.next(), PageSize::OneHundred);
9269        assert_eq!(PageSize::OneHundred.next(), PageSize::Ten);
9270    }
9271
9272    #[test]
9273    fn test_ecr_enter_drills_into_repository() {
9274        let mut app = test_app();
9275        app.current_service = Service::EcrRepositories;
9276        app.service_selected = true;
9277        app.mode = Mode::Normal;
9278        app.ecr_state.repositories.items = vec![EcrRepository {
9279            name: "my-repo".to_string(),
9280            uri: "uri".to_string(),
9281            created_at: "2023-01-01".to_string(),
9282            tag_immutability: "MUTABLE".to_string(),
9283            encryption_type: "AES256".to_string(),
9284        }];
9285
9286        app.handle_action(Action::Select);
9287        assert_eq!(
9288            app.ecr_state.current_repository,
9289            Some("my-repo".to_string())
9290        );
9291        assert!(app.ecr_state.repositories.loading);
9292    }
9293
9294    #[test]
9295    fn test_ecr_repository_expansion() {
9296        let mut app = test_app();
9297        app.current_service = Service::EcrRepositories;
9298        app.service_selected = true;
9299        app.ecr_state.repositories.items = vec![EcrRepository {
9300            name: "my-repo".to_string(),
9301            uri: "uri".to_string(),
9302            created_at: "2023-01-01".to_string(),
9303            tag_immutability: "MUTABLE".to_string(),
9304            encryption_type: "AES256".to_string(),
9305        }];
9306        app.ecr_state.repositories.selected = 0;
9307
9308        assert_eq!(app.ecr_state.repositories.expanded_item, None);
9309
9310        app.handle_action(Action::NextPane);
9311        assert_eq!(app.ecr_state.repositories.expanded_item, Some(0));
9312
9313        app.handle_action(Action::PrevPane);
9314        assert_eq!(app.ecr_state.repositories.expanded_item, None);
9315    }
9316
9317    #[test]
9318    fn test_ecr_ctrl_d_scrolls_down() {
9319        let mut app = test_app();
9320        app.current_service = Service::EcrRepositories;
9321        app.service_selected = true;
9322        app.mode = Mode::Normal;
9323        app.ecr_state.repositories.items = (0..30)
9324            .map(|i| EcrRepository {
9325                name: format!("repo{}", i),
9326                uri: format!("uri{}", i),
9327                created_at: "2023-01-01".to_string(),
9328                tag_immutability: "MUTABLE".to_string(),
9329                encryption_type: "AES256".to_string(),
9330            })
9331            .collect();
9332        app.ecr_state.repositories.selected = 0;
9333
9334        app.handle_action(Action::PageDown);
9335        assert_eq!(app.ecr_state.repositories.selected, 10);
9336    }
9337
9338    #[test]
9339    fn test_ecr_ctrl_u_scrolls_up() {
9340        let mut app = test_app();
9341        app.current_service = Service::EcrRepositories;
9342        app.service_selected = true;
9343        app.mode = Mode::Normal;
9344        app.ecr_state.repositories.items = (0..30)
9345            .map(|i| EcrRepository {
9346                name: format!("repo{}", i),
9347                uri: format!("uri{}", i),
9348                created_at: "2023-01-01".to_string(),
9349                tag_immutability: "MUTABLE".to_string(),
9350                encryption_type: "AES256".to_string(),
9351            })
9352            .collect();
9353        app.ecr_state.repositories.selected = 15;
9354
9355        app.handle_action(Action::PageUp);
9356        assert_eq!(app.ecr_state.repositories.selected, 5);
9357    }
9358
9359    #[test]
9360    fn test_ecr_images_ctrl_d_scrolls_down() {
9361        let mut app = test_app();
9362        app.current_service = Service::EcrRepositories;
9363        app.service_selected = true;
9364        app.mode = Mode::Normal;
9365        app.ecr_state.current_repository = Some("repo".to_string());
9366        app.ecr_state.images.items = (0..30)
9367            .map(|i| EcrImage {
9368                tag: format!("tag{}", i),
9369                artifact_type: "container".to_string(),
9370                pushed_at: "2023-01-01T12:00:00Z".to_string(),
9371                size_bytes: 104857600,
9372                uri: format!("uri{}", i),
9373                digest: format!("sha256:{}", i),
9374                last_pull_time: String::new(),
9375            })
9376            .collect();
9377        app.ecr_state.images.selected = 0;
9378
9379        app.handle_action(Action::PageDown);
9380        assert_eq!(app.ecr_state.images.selected, 10);
9381    }
9382
9383    #[test]
9384    fn test_ecr_esc_goes_back_from_images_to_repos() {
9385        let mut app = test_app();
9386        app.current_service = Service::EcrRepositories;
9387        app.service_selected = true;
9388        app.mode = Mode::Normal;
9389        app.ecr_state.current_repository = Some("my-repo".to_string());
9390        app.ecr_state.images.items = vec![EcrImage {
9391            tag: "latest".to_string(),
9392            artifact_type: "container".to_string(),
9393            pushed_at: "2023-01-01T12:00:00Z".to_string(),
9394            size_bytes: 104857600,
9395            uri: "uri".to_string(),
9396            digest: "sha256:abc".to_string(),
9397            last_pull_time: String::new(),
9398        }];
9399
9400        app.handle_action(Action::GoBack);
9401        assert_eq!(app.ecr_state.current_repository, None);
9402        assert!(app.ecr_state.images.items.is_empty());
9403    }
9404
9405    #[test]
9406    fn test_ecr_esc_collapses_expanded_image_first() {
9407        let mut app = test_app();
9408        app.current_service = Service::EcrRepositories;
9409        app.service_selected = true;
9410        app.mode = Mode::Normal;
9411        app.ecr_state.current_repository = Some("my-repo".to_string());
9412        app.ecr_state.images.expanded_item = Some(0);
9413
9414        app.handle_action(Action::GoBack);
9415        assert_eq!(app.ecr_state.images.expanded_item, None);
9416        assert_eq!(
9417            app.ecr_state.current_repository,
9418            Some("my-repo".to_string())
9419        );
9420    }
9421
9422    #[test]
9423    fn test_pagination_with_lowercase_p() {
9424        let mut app = test_app();
9425        app.current_service = Service::EcrRepositories;
9426        app.service_selected = true;
9427        app.mode = Mode::Normal;
9428        app.ecr_state.repositories.items = (0..100)
9429            .map(|i| EcrRepository {
9430                name: format!("repo{}", i),
9431                uri: format!("uri{}", i),
9432                created_at: "2023-01-01".to_string(),
9433                tag_immutability: "MUTABLE".to_string(),
9434                encryption_type: "AES256".to_string(),
9435            })
9436            .collect();
9437
9438        // Type "2" then "p" to go to page 2
9439        app.handle_action(Action::FilterInput('2'));
9440        assert_eq!(app.page_input, "2");
9441
9442        app.handle_action(Action::OpenColumnSelector); // 'p' key
9443        assert_eq!(app.ecr_state.repositories.selected, 50); // Page 2 starts at index 50
9444        assert_eq!(app.page_input, ""); // Should be cleared
9445    }
9446
9447    #[test]
9448    fn test_lowercase_p_without_number_opens_preferences() {
9449        let mut app = test_app();
9450        app.current_service = Service::EcrRepositories;
9451        app.service_selected = true;
9452        app.mode = Mode::Normal;
9453
9454        app.handle_action(Action::OpenColumnSelector); // 'p' key without number
9455        assert_eq!(app.mode, Mode::ColumnSelector);
9456    }
9457
9458    #[test]
9459    fn test_ctrl_o_generates_correct_console_url() {
9460        let mut app = test_app();
9461        app.current_service = Service::EcrRepositories;
9462        app.service_selected = true;
9463        app.mode = Mode::Normal;
9464        app.config.account_id = "123456789012".to_string();
9465
9466        // Test repository list URL
9467        let url = app.get_console_url();
9468        assert!(url.contains("ecr/private-registry/repositories"));
9469        assert!(url.contains("region=us-east-1"));
9470
9471        // Test images URL
9472        app.ecr_state.current_repository = Some("my-repo".to_string());
9473        let url = app.get_console_url();
9474        assert!(url.contains("ecr/repositories/private/123456789012/my-repo"));
9475        assert!(url.contains("region=us-east-1"));
9476    }
9477
9478    #[test]
9479    fn test_page_input_display_and_reset() {
9480        let mut app = test_app();
9481        app.current_service = Service::EcrRepositories;
9482        app.service_selected = true;
9483        app.mode = Mode::Normal;
9484        app.ecr_state.repositories.items = (0..100)
9485            .map(|i| EcrRepository {
9486                name: format!("repo{}", i),
9487                uri: format!("uri{}", i),
9488                created_at: "2023-01-01".to_string(),
9489                tag_immutability: "MUTABLE".to_string(),
9490                encryption_type: "AES256".to_string(),
9491            })
9492            .collect();
9493
9494        // Type "2"
9495        app.handle_action(Action::FilterInput('2'));
9496        assert_eq!(app.page_input, "2");
9497
9498        // Press 'p' to go to page 2
9499        app.handle_action(Action::OpenColumnSelector);
9500        assert_eq!(app.page_input, ""); // Should be cleared
9501        assert_eq!(app.ecr_state.repositories.selected, 50); // Page 2 starts at index 50
9502    }
9503
9504    #[test]
9505    fn test_page_navigation_updates_scroll_offset_for_cfn() {
9506        let mut app = test_app();
9507        app.current_service = Service::CloudFormationStacks;
9508        app.service_selected = true;
9509        app.mode = Mode::Normal;
9510        app.cfn_state.table.items = (0..100)
9511            .map(|i| CfnStack {
9512                name: format!("stack-{}", i),
9513                stack_id: format!(
9514                    "arn:aws:cloudformation:us-east-1:123456789012:stack/stack-{}/id",
9515                    i
9516                ),
9517                status: "CREATE_COMPLETE".to_string(),
9518                created_time: "2023-01-01T00:00:00Z".to_string(),
9519                updated_time: "2023-01-01T00:00:00Z".to_string(),
9520                deleted_time: String::new(),
9521                drift_status: "IN_SYNC".to_string(),
9522                last_drift_check_time: String::new(),
9523                status_reason: String::new(),
9524                description: String::new(),
9525                detailed_status: String::new(),
9526                root_stack: String::new(),
9527                parent_stack: String::new(),
9528                termination_protection: false,
9529                iam_role: String::new(),
9530                tags: vec![],
9531                stack_policy: String::new(),
9532                rollback_monitoring_time: String::new(),
9533                rollback_alarms: vec![],
9534                notification_arns: vec![],
9535            })
9536            .collect();
9537
9538        // Type "2" then "p" to go to page 2
9539        app.handle_action(Action::FilterInput('2'));
9540        assert_eq!(app.page_input, "2");
9541
9542        app.handle_action(Action::OpenColumnSelector); // 'p' key
9543        assert_eq!(app.page_input, ""); // Should be cleared
9544
9545        // Verify both selected and scroll_offset are updated
9546        let page_size = app.cfn_state.table.page_size.value();
9547        let expected_offset = page_size; // Page 2 starts at page_size
9548        assert_eq!(app.cfn_state.table.selected, expected_offset);
9549        assert_eq!(app.cfn_state.table.scroll_offset, expected_offset);
9550
9551        // Verify pagination display shows page 2
9552        let current_page = app.cfn_state.table.scroll_offset / page_size;
9553        assert_eq!(
9554            current_page, 1,
9555            "2p should go to page 2 (0-indexed as 1), not page 3"
9556        ); // 0-indexed, so page 2 is index 1
9557    }
9558
9559    #[test]
9560    fn test_3p_goes_to_page_3_not_page_5() {
9561        let mut app = test_app();
9562        app.current_service = Service::CloudFormationStacks;
9563        app.service_selected = true;
9564        app.mode = Mode::Normal;
9565        app.cfn_state.table.items = (0..200)
9566            .map(|i| CfnStack {
9567                name: format!("stack-{}", i),
9568                stack_id: format!(
9569                    "arn:aws:cloudformation:us-east-1:123456789012:stack/stack-{}/id",
9570                    i
9571                ),
9572                status: "CREATE_COMPLETE".to_string(),
9573                created_time: "2023-01-01T00:00:00Z".to_string(),
9574                updated_time: "2023-01-01T00:00:00Z".to_string(),
9575                deleted_time: String::new(),
9576                drift_status: "IN_SYNC".to_string(),
9577                last_drift_check_time: String::new(),
9578                status_reason: String::new(),
9579                description: String::new(),
9580                detailed_status: String::new(),
9581                root_stack: String::new(),
9582                parent_stack: String::new(),
9583                termination_protection: false,
9584                iam_role: String::new(),
9585                tags: vec![],
9586                stack_policy: String::new(),
9587                rollback_monitoring_time: String::new(),
9588                rollback_alarms: vec![],
9589                notification_arns: vec![],
9590            })
9591            .collect();
9592
9593        // Type "3" then "p" to go to page 3
9594        app.handle_action(Action::FilterInput('3'));
9595        app.handle_action(Action::OpenColumnSelector);
9596
9597        let page_size = app.cfn_state.table.page_size.value();
9598        let current_page = app.cfn_state.table.scroll_offset / page_size;
9599        assert_eq!(
9600            current_page, 2,
9601            "3p should go to page 3 (0-indexed as 2), not page 5"
9602        );
9603        assert_eq!(app.cfn_state.table.scroll_offset, 2 * page_size);
9604    }
9605
9606    #[test]
9607    fn test_log_streams_page_navigation_uses_correct_page_size() {
9608        let mut app = test_app();
9609        app.current_service = Service::CloudWatchLogGroups;
9610        app.view_mode = ViewMode::Detail;
9611        app.service_selected = true;
9612        app.mode = Mode::Normal;
9613        app.log_groups_state.log_streams = (0..100)
9614            .map(|i| LogStream {
9615                name: format!("stream-{}", i),
9616                creation_time: None,
9617                last_event_time: None,
9618            })
9619            .collect();
9620
9621        // Type "2" then "p" to go to page 2
9622        app.handle_action(Action::FilterInput('2'));
9623        app.handle_action(Action::OpenColumnSelector);
9624
9625        // Should go to page 2 (page index 1)
9626        assert_eq!(app.log_groups_state.stream_current_page, 1);
9627        assert_eq!(app.log_groups_state.selected_stream, 0);
9628
9629        // Verify pagination display shows page 2 (not page 3)
9630        assert_eq!(
9631            app.log_groups_state.stream_current_page, 1,
9632            "2p should go to page 2 (0-indexed as 1), not page 3"
9633        );
9634    }
9635
9636    #[test]
9637    fn test_ecr_repositories_page_navigation_uses_configurable_page_size() {
9638        let mut app = test_app();
9639        app.current_service = Service::EcrRepositories;
9640        app.service_selected = true;
9641        app.mode = Mode::Normal;
9642        app.ecr_state.repositories.page_size = PageSize::TwentyFive; // Set to 25
9643        app.ecr_state.repositories.items = (0..100)
9644            .map(|i| EcrRepository {
9645                name: format!("repo{}", i),
9646                uri: format!("uri{}", i),
9647                created_at: "2023-01-01".to_string(),
9648                tag_immutability: "MUTABLE".to_string(),
9649                encryption_type: "AES256".to_string(),
9650            })
9651            .collect();
9652
9653        // Type "3" then "p" to go to page 3
9654        app.handle_action(Action::FilterInput('3'));
9655        app.handle_action(Action::OpenColumnSelector);
9656
9657        // With page_size=25, page 3 starts at index 50
9658        assert_eq!(app.ecr_state.repositories.selected, 50);
9659
9660        let page_size = app.ecr_state.repositories.page_size.value();
9661        let current_page = app.ecr_state.repositories.selected / page_size;
9662        assert_eq!(
9663            current_page, 2,
9664            "3p with page_size=25 should go to page 3 (0-indexed as 2)"
9665        );
9666    }
9667
9668    #[test]
9669    fn test_page_navigation_updates_scroll_offset_for_alarms() {
9670        let mut app = test_app();
9671        app.current_service = Service::CloudWatchAlarms;
9672        app.service_selected = true;
9673        app.mode = Mode::Normal;
9674        app.alarms_state.table.items = (0..100)
9675            .map(|i| Alarm {
9676                name: format!("alarm-{}", i),
9677                state: "OK".to_string(),
9678                state_updated_timestamp: "2023-01-01T00:00:00Z".to_string(),
9679                description: String::new(),
9680                metric_name: "CPUUtilization".to_string(),
9681                namespace: "AWS/EC2".to_string(),
9682                statistic: "Average".to_string(),
9683                period: 300,
9684                comparison_operator: "GreaterThanThreshold".to_string(),
9685                threshold: 80.0,
9686                actions_enabled: true,
9687                state_reason: String::new(),
9688                resource: String::new(),
9689                dimensions: String::new(),
9690                expression: String::new(),
9691                alarm_type: "MetricAlarm".to_string(),
9692                cross_account: String::new(),
9693                ..Default::default()
9694            })
9695            .collect();
9696
9697        // Type "2" then "p" to go to page 2
9698        app.handle_action(Action::FilterInput('2'));
9699        app.handle_action(Action::OpenColumnSelector);
9700
9701        // Verify both selected and scroll_offset are updated
9702        let page_size = app.alarms_state.table.page_size.value();
9703        let expected_offset = page_size; // Page 2 starts at page_size
9704        assert_eq!(app.alarms_state.table.selected, expected_offset);
9705        assert_eq!(app.alarms_state.table.scroll_offset, expected_offset);
9706    }
9707
9708    #[test]
9709    fn test_ecr_pagination_with_65_repos() {
9710        let mut app = test_app();
9711        app.current_service = Service::EcrRepositories;
9712        app.service_selected = true;
9713        app.mode = Mode::Normal;
9714        app.ecr_state.repositories.items = (0..65)
9715            .map(|i| EcrRepository {
9716                name: format!("repo{:02}", i),
9717                uri: format!("uri{}", i),
9718                created_at: "2023-01-01".to_string(),
9719                tag_immutability: "MUTABLE".to_string(),
9720                encryption_type: "AES256".to_string(),
9721            })
9722            .collect();
9723
9724        // Page 1: items 0-49 (50 items)
9725        assert_eq!(app.ecr_state.repositories.selected, 0);
9726        let page_size = 50;
9727        let current_page = app.ecr_state.repositories.selected / page_size;
9728        assert_eq!(current_page, 0);
9729
9730        // Go to page 2
9731        app.handle_action(Action::FilterInput('2'));
9732        app.handle_action(Action::OpenColumnSelector);
9733        assert_eq!(app.ecr_state.repositories.selected, 50);
9734
9735        // Page 2: items 50-64 (15 items)
9736        let current_page = app.ecr_state.repositories.selected / page_size;
9737        assert_eq!(current_page, 1);
9738    }
9739
9740    #[test]
9741    fn test_ecr_repos_input_focus_tab_cycling() {
9742        let mut app = test_app();
9743        app.current_service = Service::EcrRepositories;
9744        app.service_selected = true;
9745        app.mode = Mode::FilterInput;
9746        app.ecr_state.input_focus = InputFocus::Filter;
9747
9748        // Tab should cycle to Pagination
9749        app.handle_action(Action::NextFilterFocus);
9750        assert_eq!(app.ecr_state.input_focus, InputFocus::Pagination);
9751
9752        // Tab again should cycle back to Input
9753        app.handle_action(Action::NextFilterFocus);
9754        assert_eq!(app.ecr_state.input_focus, InputFocus::Filter);
9755
9756        // Shift+Tab should cycle backwards to Pagination
9757        app.handle_action(Action::PrevFilterFocus);
9758        assert_eq!(app.ecr_state.input_focus, InputFocus::Pagination);
9759
9760        // Shift+Tab again should cycle back to Input
9761        app.handle_action(Action::PrevFilterFocus);
9762        assert_eq!(app.ecr_state.input_focus, InputFocus::Filter);
9763    }
9764
9765    #[test]
9766    fn test_ecr_images_column_toggle_not_off_by_one() {
9767        use crate::ecr::image::Column as ImageColumn;
9768        let mut app = test_app();
9769        app.current_service = Service::EcrRepositories;
9770        app.service_selected = true;
9771        app.mode = Mode::ColumnSelector;
9772        app.ecr_state.current_repository = Some("test-repo".to_string());
9773
9774        // Start with all columns visible
9775        app.ecr_image_visible_column_ids = ImageColumn::ids();
9776        let initial_count = app.ecr_image_visible_column_ids.len();
9777
9778        // Row 0 is the rendered Columns header and must not toggle anything.
9779        app.column_selector_index = 0;
9780        app.handle_action(Action::ToggleColumn);
9781        assert_eq!(app.ecr_image_visible_column_ids.len(), initial_count);
9782
9783        // The first rendered column is row 1, just like the repositories view.
9784        app.column_selector_index = 1;
9785        app.handle_action(Action::ToggleColumn);
9786
9787        // First column should be removed
9788        assert_eq!(app.ecr_image_visible_column_ids.len(), initial_count - 1);
9789        assert!(!app
9790            .ecr_image_visible_column_ids
9791            .contains(&ImageColumn::Tag.id()));
9792
9793        // Toggle it back
9794        app.handle_action(Action::ToggleColumn);
9795        assert_eq!(app.ecr_image_visible_column_ids.len(), initial_count);
9796        assert!(app
9797            .ecr_image_visible_column_ids
9798            .contains(&ImageColumn::Tag.id()));
9799    }
9800
9801    #[test]
9802    fn test_ecr_repos_column_toggle_works() {
9803        let mut app = test_app();
9804        app.current_service = Service::EcrRepositories;
9805        app.service_selected = true;
9806        app.mode = Mode::ColumnSelector;
9807        app.ecr_state.current_repository = None;
9808
9809        // Start with all columns visible
9810        app.ecr_repo_visible_column_ids = EcrColumn::ids();
9811        let initial_count = app.ecr_repo_visible_column_ids.len();
9812
9813        // Select first column (index 1, since 0 is header) and toggle it
9814        app.column_selector_index = 1;
9815        app.handle_action(Action::ToggleColumn);
9816
9817        // First column should be removed
9818        assert_eq!(app.ecr_repo_visible_column_ids.len(), initial_count - 1);
9819        assert!(!app
9820            .ecr_repo_visible_column_ids
9821            .contains(&EcrColumn::Name.id()));
9822
9823        // Toggle it back
9824        app.handle_action(Action::ToggleColumn);
9825        assert_eq!(app.ecr_repo_visible_column_ids.len(), initial_count);
9826        assert!(app
9827            .ecr_repo_visible_column_ids
9828            .contains(&EcrColumn::Name.id()));
9829    }
9830
9831    #[test]
9832    fn test_ecr_repos_pagination_left_right_navigation() {
9833        use crate::ecr::repo::Repository as EcrRepository;
9834        let mut app = test_app();
9835        app.current_service = Service::EcrRepositories;
9836        app.service_selected = true;
9837        app.mode = Mode::FilterInput;
9838        app.ecr_state.input_focus = InputFocus::Pagination;
9839
9840        // Create 150 repos (3 pages with page size 50)
9841        app.ecr_state.repositories.items = (0..150)
9842            .map(|i| EcrRepository {
9843                name: format!("repo{:03}", i),
9844                uri: format!("uri{}", i),
9845                created_at: "2023-01-01".to_string(),
9846                tag_immutability: "MUTABLE".to_string(),
9847                encryption_type: "AES256".to_string(),
9848            })
9849            .collect();
9850
9851        // Start on page 1 (index 0)
9852        app.ecr_state.repositories.selected = 0;
9853        eprintln!(
9854            "Initial: selected={}, focus={:?}, mode={:?}",
9855            app.ecr_state.repositories.selected, app.ecr_state.input_focus, app.mode
9856        );
9857
9858        // Right arrow (PageDown) should go to page 2
9859        app.handle_action(Action::PageDown);
9860        eprintln!(
9861            "After PageDown: selected={}",
9862            app.ecr_state.repositories.selected
9863        );
9864        assert_eq!(app.ecr_state.repositories.selected, 50);
9865
9866        // Right arrow again should go to page 3
9867        app.handle_action(Action::PageDown);
9868        eprintln!(
9869            "After 2nd PageDown: selected={}",
9870            app.ecr_state.repositories.selected
9871        );
9872        assert_eq!(app.ecr_state.repositories.selected, 100);
9873
9874        // Right arrow at last page should stay at last page
9875        app.handle_action(Action::PageDown);
9876        eprintln!(
9877            "After 3rd PageDown: selected={}",
9878            app.ecr_state.repositories.selected
9879        );
9880        assert_eq!(app.ecr_state.repositories.selected, 100);
9881
9882        // Left arrow (PageUp) should go back to page 2
9883        app.handle_action(Action::PageUp);
9884        eprintln!(
9885            "After PageUp: selected={}",
9886            app.ecr_state.repositories.selected
9887        );
9888        assert_eq!(app.ecr_state.repositories.selected, 50);
9889
9890        // Left arrow again should go to page 1
9891        app.handle_action(Action::PageUp);
9892        eprintln!(
9893            "After 2nd PageUp: selected={}",
9894            app.ecr_state.repositories.selected
9895        );
9896        assert_eq!(app.ecr_state.repositories.selected, 0);
9897
9898        // Left arrow at first page should stay at first page
9899        app.handle_action(Action::PageUp);
9900        eprintln!(
9901            "After 3rd PageUp: selected={}",
9902            app.ecr_state.repositories.selected
9903        );
9904        assert_eq!(app.ecr_state.repositories.selected, 0);
9905    }
9906
9907    #[test]
9908    fn test_ecr_repos_filter_input_when_input_focused() {
9909        use crate::ecr::repo::Repository as EcrRepository;
9910        let mut app = test_app();
9911        app.current_service = Service::EcrRepositories;
9912        app.service_selected = true;
9913        app.mode = Mode::FilterInput;
9914        app.ecr_state.input_focus = InputFocus::Filter;
9915
9916        // Create some repos
9917        app.ecr_state.repositories.items = vec![
9918            EcrRepository {
9919                name: "test-repo".to_string(),
9920                uri: "uri1".to_string(),
9921                created_at: "2023-01-01".to_string(),
9922                tag_immutability: "MUTABLE".to_string(),
9923                encryption_type: "AES256".to_string(),
9924            },
9925            EcrRepository {
9926                name: "prod-repo".to_string(),
9927                uri: "uri2".to_string(),
9928                created_at: "2023-01-01".to_string(),
9929                tag_immutability: "MUTABLE".to_string(),
9930                encryption_type: "AES256".to_string(),
9931            },
9932        ];
9933
9934        // When input is focused, typing should add to filter
9935        assert_eq!(app.ecr_state.repositories.filter, "");
9936        app.handle_action(Action::FilterInput('t'));
9937        assert_eq!(app.ecr_state.repositories.filter, "t");
9938        app.handle_action(Action::FilterInput('e'));
9939        assert_eq!(app.ecr_state.repositories.filter, "te");
9940        app.handle_action(Action::FilterInput('s'));
9941        assert_eq!(app.ecr_state.repositories.filter, "tes");
9942        app.handle_action(Action::FilterInput('t'));
9943        assert_eq!(app.ecr_state.repositories.filter, "test");
9944    }
9945
9946    #[test]
9947    fn test_ecr_repos_digit_input_when_pagination_focused() {
9948        use crate::ecr::repo::Repository as EcrRepository;
9949        let mut app = test_app();
9950        app.current_service = Service::EcrRepositories;
9951        app.service_selected = true;
9952        app.mode = Mode::FilterInput;
9953        app.ecr_state.input_focus = InputFocus::Pagination;
9954
9955        // Create some repos
9956        app.ecr_state.repositories.items = vec![EcrRepository {
9957            name: "test-repo".to_string(),
9958            uri: "uri1".to_string(),
9959            created_at: "2023-01-01".to_string(),
9960            tag_immutability: "MUTABLE".to_string(),
9961            encryption_type: "AES256".to_string(),
9962        }];
9963
9964        // When pagination is focused, digits should go to page_input, not filter
9965        assert_eq!(app.ecr_state.repositories.filter, "");
9966        assert_eq!(app.page_input, "");
9967        app.handle_action(Action::FilterInput('2'));
9968        assert_eq!(app.ecr_state.repositories.filter, "");
9969        assert_eq!(app.page_input, "2");
9970
9971        // Non-digits should not be added to either
9972        app.handle_action(Action::FilterInput('a'));
9973        assert_eq!(app.ecr_state.repositories.filter, "");
9974        assert_eq!(app.page_input, "2");
9975    }
9976
9977    #[test]
9978    fn test_ecr_repos_left_right_scrolls_table_when_input_focused() {
9979        use crate::ecr::repo::Repository as EcrRepository;
9980        let mut app = test_app();
9981        app.current_service = Service::EcrRepositories;
9982        app.service_selected = true;
9983        app.mode = Mode::FilterInput;
9984        app.ecr_state.input_focus = InputFocus::Filter;
9985
9986        // Create 150 repos (3 pages)
9987        app.ecr_state.repositories.items = (0..150)
9988            .map(|i| EcrRepository {
9989                name: format!("repo{:03}", i),
9990                uri: format!("uri{}", i),
9991                created_at: "2023-01-01".to_string(),
9992                tag_immutability: "MUTABLE".to_string(),
9993                encryption_type: "AES256".to_string(),
9994            })
9995            .collect();
9996
9997        // Start on page 1
9998        app.ecr_state.repositories.selected = 0;
9999
10000        // When input is focused, left/right should scroll table (not change pages)
10001        app.handle_action(Action::PageDown);
10002        assert_eq!(
10003            app.ecr_state.repositories.selected, 10,
10004            "Should scroll down by 10"
10005        );
10006
10007        app.handle_action(Action::PageUp);
10008        assert_eq!(
10009            app.ecr_state.repositories.selected, 0,
10010            "Should scroll back up"
10011        );
10012    }
10013
10014    #[test]
10015    fn test_ecr_repos_pagination_control_actually_works() {
10016        use crate::ecr::repo::Repository as EcrRepository;
10017
10018        // Test that verifies the exact conditions needed for pagination to work
10019        let mut app = test_app();
10020        app.current_service = Service::EcrRepositories;
10021        app.service_selected = true;
10022        app.mode = Mode::FilterInput;
10023        app.ecr_state.current_repository = None;
10024        app.ecr_state.input_focus = InputFocus::Pagination;
10025
10026        // Create 100 repos (2 pages with page size 50)
10027        app.ecr_state.repositories.items = (0..100)
10028            .map(|i| EcrRepository {
10029                name: format!("repo{:03}", i),
10030                uri: format!("uri{}", i),
10031                created_at: "2023-01-01".to_string(),
10032                tag_immutability: "MUTABLE".to_string(),
10033                encryption_type: "AES256".to_string(),
10034            })
10035            .collect();
10036
10037        app.ecr_state.repositories.selected = 0;
10038
10039        // Verify all conditions are met
10040        assert_eq!(app.mode, Mode::FilterInput);
10041        assert_eq!(app.current_service, Service::EcrRepositories);
10042        assert_eq!(app.ecr_state.current_repository, None);
10043        assert_eq!(app.ecr_state.input_focus, InputFocus::Pagination);
10044
10045        // Now test pagination
10046        app.handle_action(Action::PageDown);
10047        assert_eq!(
10048            app.ecr_state.repositories.selected, 50,
10049            "PageDown should move to page 2"
10050        );
10051
10052        app.handle_action(Action::PageUp);
10053        assert_eq!(
10054            app.ecr_state.repositories.selected, 0,
10055            "PageUp should move back to page 1"
10056        );
10057    }
10058
10059    #[test]
10060    fn test_ecr_repos_can_reach_last_page_when_not_multiple_of_page_size() {
10061        use crate::ecr::repo::Repository as EcrRepository;
10062        let mut app = test_app();
10063        app.current_service = Service::EcrRepositories;
10064        app.service_selected = true;
10065        app.mode = Mode::FilterInput;
10066        app.ecr_state.input_focus = InputFocus::Pagination;
10067
10068        // 754 repos, page size 50 — last page starts at 750 (page 16)
10069        app.ecr_state.repositories.items = (0..754)
10070            .map(|i| EcrRepository {
10071                name: format!("repo{:03}", i),
10072                uri: format!("uri{}", i),
10073                created_at: "2023-01-01".to_string(),
10074                tag_immutability: "MUTABLE".to_string(),
10075                encryption_type: "AES256".to_string(),
10076            })
10077            .collect();
10078
10079        // Navigate to page 15 (selected = 700)
10080        app.ecr_state.repositories.selected = 700;
10081
10082        // Right arrow must reach page 16 (selected = 750)
10083        app.handle_action(Action::PageDown);
10084        assert_eq!(
10085            app.ecr_state.repositories.selected, 750,
10086            "Should reach last page (750) but got {}",
10087            app.ecr_state.repositories.selected
10088        );
10089
10090        // Right arrow again must stay at 750 (can't go past last page)
10091        app.handle_action(Action::PageDown);
10092        assert_eq!(app.ecr_state.repositories.selected, 750);
10093    }
10094
10095    #[test]
10096    fn test_ecr_repos_start_filter_resets_focus_to_input() {
10097        let mut app = test_app();
10098        app.current_service = Service::EcrRepositories;
10099        app.service_selected = true;
10100        app.mode = Mode::Normal;
10101        app.ecr_state.current_repository = None;
10102
10103        // Set focus to Pagination
10104        app.ecr_state.input_focus = InputFocus::Pagination;
10105
10106        // Start filter mode
10107        app.handle_action(Action::StartFilter);
10108
10109        // Should reset to Input focus
10110        assert_eq!(app.mode, Mode::FilterInput);
10111        assert_eq!(app.ecr_state.input_focus, InputFocus::Filter);
10112    }
10113
10114    #[test]
10115    fn test_ecr_repos_exact_user_flow_i_tab_arrow() {
10116        use crate::ecr::repo::Repository as EcrRepository;
10117
10118        let mut app = test_app();
10119        app.current_service = Service::EcrRepositories;
10120        app.service_selected = true;
10121        app.mode = Mode::Normal;
10122        app.ecr_state.current_repository = None;
10123
10124        // Create 100 repos (2 pages)
10125        app.ecr_state.repositories.items = (0..100)
10126            .map(|i| EcrRepository {
10127                name: format!("repo{:03}", i),
10128                uri: format!("uri{}", i),
10129                created_at: "2023-01-01".to_string(),
10130                tag_immutability: "MUTABLE".to_string(),
10131                encryption_type: "AES256".to_string(),
10132            })
10133            .collect();
10134
10135        app.ecr_state.repositories.selected = 0;
10136
10137        // User presses 'i' to enter filter mode
10138        app.handle_action(Action::StartFilter);
10139        assert_eq!(app.mode, Mode::FilterInput);
10140        assert_eq!(app.ecr_state.input_focus, InputFocus::Filter);
10141
10142        // User presses Tab to switch to pagination
10143        app.handle_action(Action::NextFilterFocus);
10144        assert_eq!(app.ecr_state.input_focus, InputFocus::Pagination);
10145
10146        // User presses right arrow (PageDown)
10147        eprintln!("Before PageDown: mode={:?}, service={:?}, current_repo={:?}, input_focus={:?}, selected={}",
10148            app.mode, app.current_service, app.ecr_state.current_repository, app.ecr_state.input_focus, app.ecr_state.repositories.selected);
10149        app.handle_action(Action::PageDown);
10150        eprintln!(
10151            "After PageDown: selected={}",
10152            app.ecr_state.repositories.selected
10153        );
10154
10155        // Should move to page 2
10156        assert_eq!(
10157            app.ecr_state.repositories.selected, 50,
10158            "Right arrow should move to page 2"
10159        );
10160
10161        // User presses left arrow (PageUp)
10162        app.handle_action(Action::PageUp);
10163        assert_eq!(
10164            app.ecr_state.repositories.selected, 0,
10165            "Left arrow should move back to page 1"
10166        );
10167    }
10168
10169    #[test]
10170    fn test_apig_filter_input() {
10171        let mut app = test_app();
10172        app.current_service = Service::ApiGatewayApis;
10173        app.service_selected = true;
10174        app.mode = Mode::FilterInput;
10175
10176        app.handle_action(Action::FilterInput('t'));
10177        app.handle_action(Action::FilterInput('e'));
10178        app.handle_action(Action::FilterInput('s'));
10179        app.handle_action(Action::FilterInput('t'));
10180        assert_eq!(app.apig_state.apis.filter, "test");
10181
10182        app.handle_action(Action::FilterBackspace);
10183        assert_eq!(app.apig_state.apis.filter, "tes");
10184    }
10185
10186    #[test]
10187    fn test_apig_start_filter_enters_filter_mode() {
10188        let mut app = test_app();
10189        app.current_service = Service::ApiGatewayApis;
10190        app.service_selected = true;
10191        app.mode = Mode::Normal;
10192
10193        app.handle_action(Action::StartFilter);
10194        assert_eq!(app.mode, Mode::FilterInput);
10195        assert_eq!(app.apig_state.input_focus, InputFocus::Filter);
10196    }
10197
10198    #[test]
10199    fn test_apig_input_focus_cycles_with_tab() {
10200        let mut app = test_app();
10201        app.current_service = Service::ApiGatewayApis;
10202        app.service_selected = true;
10203        app.mode = Mode::FilterInput;
10204        app.apig_state.input_focus = InputFocus::Filter;
10205
10206        // Tab cycles from Filter to Pagination
10207        app.handle_action(Action::NextFilterFocus);
10208        assert_eq!(app.apig_state.input_focus, InputFocus::Pagination);
10209
10210        // Tab cycles back to Filter
10211        app.handle_action(Action::NextFilterFocus);
10212        assert_eq!(app.apig_state.input_focus, InputFocus::Filter);
10213    }
10214
10215    #[test]
10216    fn test_apig_input_focus_cycles_with_shift_tab() {
10217        let mut app = test_app();
10218        app.current_service = Service::ApiGatewayApis;
10219        app.service_selected = true;
10220        app.mode = Mode::FilterInput;
10221        app.apig_state.input_focus = InputFocus::Filter;
10222
10223        // Shift+Tab cycles from Filter to Pagination (backward)
10224        app.handle_action(Action::PrevFilterFocus);
10225        assert_eq!(app.apig_state.input_focus, InputFocus::Pagination);
10226
10227        // Shift+Tab cycles back to Filter
10228        app.handle_action(Action::PrevFilterFocus);
10229        assert_eq!(app.apig_state.input_focus, InputFocus::Filter);
10230    }
10231
10232    #[test]
10233    fn test_apig_exact_user_flow_i_tab_filter() {
10234        let mut app = test_app();
10235        app.current_service = Service::ApiGatewayApis;
10236        app.service_selected = true;
10237        app.mode = Mode::Normal;
10238
10239        // Create test APIs
10240        app.apig_state.apis.items = vec![
10241            crate::apig::api::RestApi {
10242                id: "api1".to_string(),
10243                name: "test-api".to_string(),
10244                description: "Test API".to_string(),
10245                created_date: "2023-01-01".to_string(),
10246                api_key_source: "HEADER".to_string(),
10247                endpoint_configuration: "REGIONAL".to_string(),
10248                protocol_type: "REST".to_string(),
10249                disable_execute_api_endpoint: false,
10250                status: "AVAILABLE".to_string(),
10251            },
10252            crate::apig::api::RestApi {
10253                id: "api2".to_string(),
10254                name: "prod-api".to_string(),
10255                description: "Production API".to_string(),
10256                created_date: "2023-01-02".to_string(),
10257                api_key_source: "HEADER".to_string(),
10258                endpoint_configuration: "REGIONAL".to_string(),
10259                protocol_type: "REST".to_string(),
10260                disable_execute_api_endpoint: false,
10261                status: "AVAILABLE".to_string(),
10262            },
10263        ];
10264
10265        // User presses 'i' to enter filter mode
10266        app.handle_action(Action::StartFilter);
10267        assert_eq!(app.mode, Mode::FilterInput);
10268        assert_eq!(app.apig_state.input_focus, InputFocus::Filter);
10269
10270        // User types "test"
10271        app.handle_action(Action::FilterInput('t'));
10272        app.handle_action(Action::FilterInput('e'));
10273        app.handle_action(Action::FilterInput('s'));
10274        app.handle_action(Action::FilterInput('t'));
10275        assert_eq!(app.apig_state.apis.filter, "test");
10276
10277        // User presses Tab to switch to pagination
10278        app.handle_action(Action::NextFilterFocus);
10279        assert_eq!(app.apig_state.input_focus, InputFocus::Pagination);
10280
10281        // User presses Tab again to go back to filter
10282        app.handle_action(Action::NextFilterFocus);
10283        assert_eq!(app.apig_state.input_focus, InputFocus::Filter);
10284    }
10285
10286    #[test]
10287    fn test_apig_row_expansion() {
10288        let mut app = test_app();
10289        app.current_service = Service::ApiGatewayApis;
10290        app.service_selected = true;
10291        app.apig_state.apis.items = vec![crate::apig::api::RestApi {
10292            id: "api1".to_string(),
10293            name: "test-api".to_string(),
10294            description: "Test API".to_string(),
10295            created_date: "2023-01-01".to_string(),
10296            api_key_source: "HEADER".to_string(),
10297            endpoint_configuration: "REGIONAL".to_string(),
10298            protocol_type: "REST".to_string(),
10299            disable_execute_api_endpoint: false,
10300            status: "AVAILABLE".to_string(),
10301        }];
10302        app.apig_state.apis.selected = 0;
10303
10304        assert_eq!(app.apig_state.apis.expanded_item, None);
10305
10306        // Right arrow (NextPane) should NOT expand rows
10307        app.handle_action(Action::NextPane);
10308        assert_eq!(app.apig_state.apis.expanded_item, None);
10309
10310        // Manually expand using ExpandRow action
10311        app.handle_action(Action::ExpandRow);
10312        assert_eq!(app.apig_state.apis.expanded_item, Some(0));
10313
10314        // Right arrow should NOT collapse expanded row
10315        app.handle_action(Action::NextPane);
10316        assert_eq!(app.apig_state.apis.expanded_item, Some(0));
10317
10318        // Left arrow (PrevPane) should collapse
10319        app.handle_action(Action::PrevPane);
10320        assert_eq!(app.apig_state.apis.expanded_item, None);
10321    }
10322
10323    #[test]
10324    fn test_apig_column_preferences() {
10325        let mut app = test_app();
10326        app.current_service = Service::ApiGatewayApis;
10327        app.service_selected = true;
10328
10329        // All columns should be visible by default
10330        let initial_count = app.apig_api_visible_column_ids.len();
10331        assert_eq!(initial_count, app.apig_api_column_ids.len());
10332
10333        // Open preferences
10334        app.handle_action(Action::OpenColumnSelector);
10335        assert_eq!(app.mode, Mode::ColumnSelector);
10336
10337        // Toggle first column (index 1, since 0 is header)
10338        app.column_selector_index = 1;
10339        let first_col = app.apig_api_column_ids[0];
10340        assert!(app.apig_api_visible_column_ids.contains(&first_col));
10341
10342        app.handle_action(Action::ToggleColumn);
10343
10344        // First column should now be hidden
10345        assert!(!app.apig_api_visible_column_ids.contains(&first_col));
10346        assert_eq!(app.apig_api_visible_column_ids.len(), initial_count - 1);
10347
10348        // Toggle it back
10349        app.handle_action(Action::ToggleColumn);
10350        assert!(app.apig_api_visible_column_ids.contains(&first_col));
10351        assert_eq!(app.apig_api_visible_column_ids.len(), initial_count);
10352    }
10353
10354    #[test]
10355    fn test_apig_page_size_preferences() {
10356        use crate::common::PageSize;
10357
10358        let mut app = test_app();
10359        app.current_service = Service::ApiGatewayApis;
10360        app.service_selected = true;
10361
10362        // Default page size
10363        assert_eq!(app.apig_state.apis.page_size, PageSize::Fifty);
10364
10365        // Open preferences
10366        app.handle_action(Action::OpenColumnSelector);
10367
10368        // Page size options start after: header (1) + columns (8) + blank line (1) + "Page Size" header (1) = 11
10369        // So indices are: 11 (header), 12 (10), 13 (25), 14 (50), 15 (100)
10370        // But the code uses: column_count + 3, +4, +5, +6
10371        // With 8 columns: 11, 12, 13, 14
10372        app.column_selector_index = app.apig_api_column_ids.len() + 3;
10373        app.handle_action(Action::ToggleColumn);
10374        assert_eq!(app.apig_state.apis.page_size, PageSize::Ten);
10375
10376        // Select page size 100
10377        app.column_selector_index = app.apig_api_column_ids.len() + 6;
10378        app.handle_action(Action::ToggleColumn);
10379        assert_eq!(app.apig_state.apis.page_size, PageSize::OneHundred);
10380    }
10381
10382    #[test]
10383    fn test_apig_preferences_skip_blank_row() {
10384        let mut app = test_app();
10385        app.current_service = Service::ApiGatewayApis;
10386        app.service_selected = true;
10387        app.mode = Mode::ColumnSelector;
10388
10389        // Start at last column (index 8)
10390        app.column_selector_index = 8;
10391
10392        // Next should skip blank row (9) and go to page size header (10)
10393        app.handle_action(Action::NextItem);
10394        assert_eq!(app.column_selector_index, 10);
10395
10396        // Prev should skip blank row (9) and go back to last column (8)
10397        app.handle_action(Action::PrevItem);
10398        assert_eq!(app.column_selector_index, 8);
10399    }
10400
10401    #[test]
10402    fn test_apig_preferences_ctrl_d_skip_blank_row() {
10403        let mut app = test_app();
10404        app.current_service = Service::ApiGatewayApis;
10405        app.service_selected = true;
10406        app.mode = Mode::ColumnSelector;
10407
10408        // Start at index 0
10409        app.column_selector_index = 0;
10410
10411        // Ctrl+D (PageDown) by 10 would land on blank row (9), should skip to 10
10412        app.handle_action(Action::PageDown);
10413        assert_eq!(app.column_selector_index, 10);
10414
10415        // Ctrl+U (PageUp) by 10 would land on 0
10416        app.handle_action(Action::PageUp);
10417        assert_eq!(app.column_selector_index, 0);
10418    }
10419
10420    #[test]
10421    fn test_apig_preferences_ctrl_u_skip_blank_row() {
10422        let mut app = test_app();
10423        app.current_service = Service::ApiGatewayApis;
10424        app.service_selected = true;
10425        app.mode = Mode::ColumnSelector;
10426
10427        // Start at page size header (index 10)
10428        app.column_selector_index = 10;
10429
10430        // Ctrl+U (PageUp) by 10 would land on 0
10431        app.handle_action(Action::PageUp);
10432        assert_eq!(app.column_selector_index, 0);
10433
10434        // Go to index 19 (if it exists, otherwise use 14 which is max)
10435        app.column_selector_index = 14; // Max index for APIG
10436
10437        // Ctrl+U (PageUp) by 10 would land on 4
10438        app.handle_action(Action::PageUp);
10439        assert_eq!(app.column_selector_index, 4);
10440    }
10441
10442    #[test]
10443    fn test_apig_preferences_tab_cycles_sections() {
10444        let mut app = test_app();
10445        app.current_service = Service::ApiGatewayApis;
10446        app.service_selected = true;
10447        app.mode = Mode::ColumnSelector;
10448
10449        // Start at columns header (0)
10450        app.column_selector_index = 0;
10451
10452        // Tab should jump to page size section (column_count + 2 = 10)
10453        app.handle_action(Action::NextPreferences);
10454        assert_eq!(app.column_selector_index, 10);
10455
10456        // Tab again should wrap back to columns (0)
10457        app.handle_action(Action::NextPreferences);
10458        assert_eq!(app.column_selector_index, 0);
10459    }
10460
10461    #[test]
10462    fn test_apig_preferences_shift_tab_cycles_sections() {
10463        let mut app = test_app();
10464        app.current_service = Service::ApiGatewayApis;
10465        app.service_selected = true;
10466        app.mode = Mode::ColumnSelector;
10467
10468        // Start at columns header (0)
10469        app.column_selector_index = 0;
10470
10471        // Shift+Tab should jump to page size section (10)
10472        app.handle_action(Action::PrevPreferences);
10473        assert_eq!(app.column_selector_index, 10);
10474
10475        // Shift+Tab again should wrap back to columns (0)
10476        app.handle_action(Action::PrevPreferences);
10477        assert_eq!(app.column_selector_index, 0);
10478    }
10479
10480    #[test]
10481    fn test_apig_arrow_navigation() {
10482        let mut app = test_app();
10483        app.current_service = Service::ApiGatewayApis;
10484        app.service_selected = true;
10485        app.mode = Mode::Normal; // Ensure we're in Normal mode
10486        app.apig_state.apis.filter = String::new(); // Ensure filter is empty
10487        app.apig_state.apis.items = vec![
10488            crate::apig::api::RestApi {
10489                id: "api1".to_string(),
10490                name: "test-api-1".to_string(),
10491                description: "Test API 1".to_string(),
10492                created_date: "2023-01-01".to_string(),
10493                api_key_source: "HEADER".to_string(),
10494                endpoint_configuration: "REGIONAL".to_string(),
10495                protocol_type: "REST".to_string(),
10496                disable_execute_api_endpoint: false,
10497                status: "AVAILABLE".to_string(),
10498            },
10499            crate::apig::api::RestApi {
10500                id: "api2".to_string(),
10501                name: "test-api-2".to_string(),
10502                description: "Test API 2".to_string(),
10503                created_date: "2023-01-02".to_string(),
10504                api_key_source: "HEADER".to_string(),
10505                endpoint_configuration: "REGIONAL".to_string(),
10506                protocol_type: "HTTP".to_string(),
10507                disable_execute_api_endpoint: false,
10508                status: "AVAILABLE".to_string(),
10509            },
10510        ];
10511        app.apig_state.apis.selected = 0;
10512
10513        // Down arrow should move to next item
10514        app.handle_action(Action::NextItem);
10515        assert_eq!(app.apig_state.apis.selected, 1);
10516
10517        // Up arrow should move to previous item
10518        app.handle_action(Action::PrevItem);
10519        assert_eq!(app.apig_state.apis.selected, 0);
10520    }
10521
10522    #[test]
10523    fn test_apig_collapse_row() {
10524        let mut app = test_app();
10525        app.current_service = Service::ApiGatewayApis;
10526        app.service_selected = true;
10527        app.apig_state.apis.items = vec![crate::apig::api::RestApi {
10528            id: "api1".to_string(),
10529            name: "test-api".to_string(),
10530            description: "Test API".to_string(),
10531            created_date: "2023-01-01".to_string(),
10532            api_key_source: "HEADER".to_string(),
10533            endpoint_configuration: "REGIONAL".to_string(),
10534            protocol_type: "REST".to_string(),
10535            disable_execute_api_endpoint: false,
10536            status: "AVAILABLE".to_string(),
10537        }];
10538        app.apig_state.apis.selected = 0;
10539        app.apig_state.apis.expanded_item = Some(0);
10540
10541        // Left arrow should collapse
10542        app.handle_action(Action::CollapseRow);
10543        assert_eq!(app.apig_state.apis.expanded_item, None);
10544    }
10545
10546    #[test]
10547    fn test_apig_filter_resets_selection() {
10548        let mut app = test_app();
10549        app.current_service = Service::ApiGatewayApis;
10550        app.service_selected = true;
10551        app.mode = Mode::FilterInput;
10552        app.apig_state.input_focus = InputFocus::Filter;
10553        app.apig_state.apis.items = vec![
10554            crate::apig::api::RestApi {
10555                id: "api1".to_string(),
10556                name: "alpha-api".to_string(),
10557                description: "Alpha API".to_string(),
10558                created_date: "2023-01-01".to_string(),
10559                api_key_source: "HEADER".to_string(),
10560                endpoint_configuration: "REGIONAL".to_string(),
10561                protocol_type: "REST".to_string(),
10562                disable_execute_api_endpoint: false,
10563                status: "AVAILABLE".to_string(),
10564            },
10565            crate::apig::api::RestApi {
10566                id: "api2".to_string(),
10567                name: "beta-api".to_string(),
10568                description: "Beta API".to_string(),
10569                created_date: "2023-01-02".to_string(),
10570                api_key_source: "HEADER".to_string(),
10571                endpoint_configuration: "REGIONAL".to_string(),
10572                protocol_type: "HTTP".to_string(),
10573                disable_execute_api_endpoint: false,
10574                status: "AVAILABLE".to_string(),
10575            },
10576        ];
10577
10578        // Select second item and expand it
10579        app.apig_state.apis.selected = 1;
10580        app.apig_state.apis.expanded_item = Some(1);
10581
10582        // Type a filter character
10583        app.handle_action(Action::FilterInput('a'));
10584
10585        // Selection and expansion should be reset
10586        assert_eq!(app.apig_state.apis.selected, 0);
10587        assert_eq!(app.apig_state.apis.expanded_item, None);
10588        assert_eq!(app.apig_state.apis.filter, "a");
10589    }
10590
10591    #[test]
10592    fn test_service_picker_starts_in_normal_mode() {
10593        let app = test_app();
10594        assert_eq!(app.mode, Mode::ServicePicker);
10595        assert!(!app.service_picker.filter_active);
10596    }
10597
10598    #[test]
10599    fn test_service_picker_i_key_activates_filter() {
10600        let mut app = test_app();
10601
10602        // Start in ServicePicker mode (Normal mode - filter not active)
10603        assert_eq!(app.mode, Mode::ServicePicker);
10604        assert!(!app.service_picker.filter_active);
10605        assert!(app.service_picker.filter.is_empty());
10606
10607        // Press 'i' to enter filter mode
10608        app.handle_action(Action::EnterFilterMode);
10609
10610        // Should still be in ServicePicker mode but filter should be active
10611        assert_eq!(app.mode, Mode::ServicePicker);
10612        assert!(app.service_picker.filter_active);
10613        assert!(app.service_picker.filter.is_empty());
10614
10615        // Now typing should work
10616        app.handle_action(Action::FilterInput('s'));
10617        assert_eq!(app.service_picker.filter, "s");
10618    }
10619
10620    #[test]
10621    fn test_service_picker_esc_exits_filter_mode() {
10622        let mut app = test_app();
10623        assert_eq!(app.mode, Mode::ServicePicker);
10624        assert!(!app.service_picker.filter_active);
10625
10626        // Enter filter mode
10627        app.handle_action(Action::EnterFilterMode);
10628        assert!(app.service_picker.filter_active);
10629        assert_eq!(app.mode, Mode::ServicePicker);
10630
10631        // Type something
10632        app.handle_action(Action::FilterInput('s'));
10633        assert_eq!(app.service_picker.filter, "s");
10634
10635        // Esc should exit filter mode (not close menu)
10636        app.handle_action(Action::ExitFilterMode);
10637        assert!(!app.service_picker.filter_active);
10638        assert_eq!(app.mode, Mode::ServicePicker); // Still in picker
10639
10640        // Second Esc should close menu
10641        app.handle_action(Action::ExitFilterMode);
10642        assert_eq!(app.mode, Mode::Normal);
10643    }
10644
10645    #[test]
10646    fn test_service_picker_navigation_only_works_in_normal_mode() {
10647        let mut app = test_app();
10648        app.service_picker.selected = 0;
10649
10650        // In normal mode, navigation should work
10651        assert!(!app.service_picker.filter_active);
10652        app.handle_action(Action::NextItem);
10653        assert_eq!(app.service_picker.selected, 1);
10654
10655        // Enter filter mode
10656        app.handle_action(Action::EnterFilterMode);
10657        assert!(app.service_picker.filter_active);
10658
10659        // In filter mode, navigation should NOT work
10660        let prev_selected = app.service_picker.selected;
10661        app.handle_action(Action::NextItem);
10662        assert_eq!(app.service_picker.selected, prev_selected); // Unchanged
10663
10664        // Exit filter mode
10665        app.handle_action(Action::ExitFilterMode);
10666        assert!(!app.service_picker.filter_active);
10667
10668        // Navigation should work again
10669        app.handle_action(Action::NextItem);
10670        assert_eq!(app.service_picker.selected, prev_selected + 1);
10671    }
10672
10673    #[test]
10674    fn test_service_picker_typing_filters_services() {
10675        let mut app = test_app();
10676
10677        // Start in ServicePicker mode
10678        assert_eq!(app.mode, Mode::ServicePicker);
10679        assert!(!app.service_picker.filter_active);
10680
10681        // Enter filter mode
10682        app.handle_action(Action::EnterFilterMode);
10683        assert!(app.service_picker.filter_active);
10684
10685        // Type "s3" to filter
10686        app.handle_action(Action::FilterInput('s'));
10687        app.handle_action(Action::FilterInput('3'));
10688
10689        assert_eq!(app.service_picker.filter, "s3");
10690        assert_eq!(app.mode, Mode::ServicePicker);
10691    }
10692
10693    #[test]
10694    fn test_service_picker_enter_exits_insert_mode() {
10695        let mut app = test_app();
10696        assert_eq!(app.mode, Mode::ServicePicker);
10697        assert!(!app.service_selected);
10698
10699        // Enter filter mode (INSERT mode)
10700        app.handle_action(Action::EnterFilterMode);
10701        assert!(app.service_picker.filter_active);
10702
10703        // Type to filter
10704        app.handle_action(Action::FilterInput('s'));
10705        app.handle_action(Action::FilterInput('3'));
10706
10707        // Press Enter - should exit INSERT mode, not select service
10708        app.handle_action(Action::Select);
10709        assert!(!app.service_selected, "Should not select service");
10710        assert!(!app.service_picker.filter_active, "Should exit INSERT mode");
10711        assert_eq!(app.mode, Mode::ServicePicker);
10712
10713        // Now press Enter again in NORMAL mode - should select service
10714        app.handle_action(Action::Select);
10715        assert!(app.service_selected, "Should select service in NORMAL mode");
10716        assert_eq!(app.mode, Mode::Normal);
10717    }
10718
10719    #[test]
10720    fn test_service_picker_resets_on_open() {
10721        let mut app = test_app();
10722
10723        // Select a service to get into Normal mode
10724        app.service_selected = true;
10725        app.mode = Mode::Normal;
10726
10727        // Simulate having previous filter and selection
10728        app.service_picker.filter = "previous".to_string();
10729        app.service_picker.filter_active = true;
10730        app.service_picker.selected = 5;
10731
10732        // Open space menu (service picker)
10733        app.handle_action(Action::OpenSpaceMenu);
10734
10735        // Filter, filter_active, and selection should be reset
10736        assert_eq!(app.mode, Mode::SpaceMenu);
10737        assert!(app.service_picker.filter.is_empty());
10738        assert!(!app.service_picker.filter_active);
10739        assert_eq!(app.service_picker.selected, 0);
10740    }
10741
10742    #[test]
10743    fn test_kms_in_service_picker_services_list() {
10744        let app = test_app();
10745        assert!(
10746            app.service_picker.services.contains(&"KMS › Managed Keys"),
10747            "KMS › Managed Keys must be in the service picker list"
10748        );
10749    }
10750
10751    #[test]
10752    fn test_kms_service_picker_selects_kms_service() {
10753        let mut app = test_app();
10754        app.mode = Mode::ServicePicker;
10755        app.service_picker.filter_active = true;
10756
10757        // Type "kms" into the filter
10758        for c in ['k', 'm', 's'] {
10759            app.handle_action(Action::FilterInput(c));
10760        }
10761
10762        assert_eq!(app.service_picker.filter, "kms");
10763
10764        // The filtered list should contain "KMS › Managed Keys"
10765        let filtered: Vec<_> = app
10766            .service_picker
10767            .services
10768            .iter()
10769            .filter(|s| s.to_lowercase().contains("kms"))
10770            .collect();
10771        assert_eq!(filtered.len(), 1);
10772        assert_eq!(*filtered[0], "KMS › Managed Keys");
10773    }
10774
10775    #[test]
10776    fn test_selecting_kms_in_picker_switches_service() {
10777        let mut app = test_app();
10778        app.mode = Mode::ServicePicker;
10779        app.service_selected = false;
10780
10781        // Find the index of "KMS › Managed Keys" and select it
10782        // filtered_services() sorts alphabetically, so use that for the correct index
10783        let kms_idx = app
10784            .filtered_services()
10785            .iter()
10786            .position(|&s| s == "KMS › Managed Keys")
10787            .expect("KMS › Managed Keys must be in services list");
10788        app.service_picker.selected = kms_idx;
10789
10790        app.handle_action(Action::Select);
10791
10792        assert_eq!(app.current_service, Service::KmsKeys);
10793        assert!(app.service_selected);
10794    }
10795
10796    #[test]
10797    fn test_kms_picker_creates_session_tab_with_correct_title() {
10798        // Session tab title must be "KMS › Managed Keys" so it shows in the tabs row
10799        let mut app = test_app();
10800        app.mode = Mode::ServicePicker;
10801        app.service_selected = false;
10802
10803        let kms_idx = app
10804            .filtered_services()
10805            .iter()
10806            .position(|&s| s == "KMS › Managed Keys")
10807            .unwrap();
10808        app.service_picker.selected = kms_idx;
10809        app.handle_action(Action::Select);
10810
10811        assert!(!app.tabs.is_empty(), "a session tab must be created");
10812        let tab = &app.tabs[app.current_tab];
10813        assert_eq!(
10814            tab.title, "KMS › Managed Keys",
10815            "session tab title must match picker label"
10816        );
10817        assert_eq!(tab.service, Service::KmsKeys);
10818    }
10819
10820    #[test]
10821    fn test_kms_tab_switch_does_not_trigger_reload() {
10822        // Switching between AWS managed / Customer managed must NOT set loading=true.
10823        // The data is filtered client-side; re-fetching would show "0" briefly.
10824        use crate::ui::kms::Tab as KmsTab;
10825        let mut app = test_app();
10826        app.current_service = Service::KmsKeys;
10827        app.service_selected = true;
10828        app.mode = Mode::Normal;
10829        app.kms_state.tab = KmsTab::AwsManaged;
10830        app.kms_state.keys.loading = false;
10831
10832        app.handle_action(Action::NextDetailTab);
10833
10834        assert_eq!(app.kms_state.tab, KmsTab::CustomerManaged);
10835        assert!(
10836            !app.kms_state.keys.loading,
10837            "tab switch must NOT trigger a reload — keys are filtered client-side"
10838        );
10839    }
10840
10841    #[test]
10842    fn test_kms_tab_switching_cycles() {
10843        use crate::ui::kms::Tab as KmsTab;
10844        let mut app = test_app();
10845        app.current_service = Service::KmsKeys;
10846        app.service_selected = true;
10847        app.mode = Mode::Normal;
10848
10849        assert_eq!(app.kms_state.tab, KmsTab::AwsManaged);
10850
10851        app.handle_action(Action::NextDetailTab);
10852        assert_eq!(app.kms_state.tab, KmsTab::CustomerManaged);
10853
10854        app.handle_action(Action::NextDetailTab);
10855        assert_eq!(app.kms_state.tab, KmsTab::AwsManaged, "must wrap around");
10856
10857        app.handle_action(Action::PrevDetailTab);
10858        assert_eq!(app.kms_state.tab, KmsTab::CustomerManaged);
10859    }
10860
10861    #[test]
10862    fn test_kms_load_condition_triggers_on_loading_flag() {
10863        // After region change, main.rs sets kms_state.keys.loading = true.
10864        // Verify the load condition (loading == true) is independent of prev_service.
10865        // This simulates: same service, same region → loading=true forces re-fetch.
10866        let mut app = test_app();
10867        app.current_service = Service::KmsKeys;
10868        app.service_selected = true;
10869        app.kms_state.keys.loading = false;
10870
10871        // Simulate what region change does: set loading=true
10872        app.kms_state.keys.loading = true;
10873
10874        // The main.rs condition is: kms_state.keys.loading == true
10875        // Just verify the flag is set correctly
10876        assert!(
10877            app.kms_state.keys.loading,
10878            "loading flag must be true after region change to trigger reload"
10879        );
10880    }
10881
10882    #[test]
10883    fn test_kms_session_tab_survives_service_switch() {
10884        // When KMS is selected from picker, a session tab is created.
10885        // This tab must remain in app.tabs.
10886        let mut app = test_app();
10887        app.mode = Mode::ServicePicker;
10888        app.service_selected = false;
10889
10890        let kms_idx = app
10891            .filtered_services()
10892            .iter()
10893            .position(|&s| s == "KMS › Managed Keys")
10894            .unwrap();
10895        app.service_picker.selected = kms_idx;
10896        app.handle_action(Action::Select);
10897
10898        assert_eq!(app.tabs.len(), 1);
10899        assert_eq!(app.tabs[0].title, "KMS › Managed Keys");
10900        assert_eq!(app.tabs[0].service, Service::KmsKeys);
10901        assert_eq!(app.current_tab, 0);
10902    }
10903
10904    #[test]
10905    fn test_no_pii_in_test_data() {
10906        // Ensure test data uses placeholder account IDs, not real ones
10907        let test_repo = EcrRepository {
10908            name: "test-repo".to_string(),
10909            uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
10910            created_at: "2024-01-01".to_string(),
10911            tag_immutability: "MUTABLE".to_string(),
10912            encryption_type: "AES256".to_string(),
10913        };
10914
10915        // Verify placeholder account ID is used
10916        assert!(test_repo.uri.starts_with("123456789012"));
10917        assert!(!test_repo.uri.contains("123456789013")); // Not a real account
10918    }
10919
10920    #[test]
10921    fn test_lambda_versions_tab_triggers_loading() {
10922        let mut app = test_app();
10923        app.current_service = Service::LambdaFunctions;
10924        app.service_selected = true;
10925
10926        // Simulate selecting a function
10927        app.lambda_state.current_function = Some("test-function".to_string());
10928        app.lambda_state.detail_tab = LambdaDetailTab::Code;
10929
10930        // Initially no versions
10931        assert!(app.lambda_state.version_table.items.is_empty());
10932
10933        // Switch to Versions tab
10934        app.lambda_state.detail_tab = LambdaDetailTab::Versions;
10935
10936        // The main loop should detect this change and load versions
10937        // We verify the state is set up correctly for loading
10938        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Versions);
10939        assert!(app.lambda_state.current_function.is_some());
10940    }
10941
10942    #[test]
10943    fn test_lambda_versions_navigation() {
10944        let mut app = test_app();
10945        app.current_service = Service::LambdaFunctions;
10946        app.service_selected = true;
10947        app.lambda_state.current_function = Some("test-function".to_string());
10948        app.lambda_state.detail_tab = LambdaDetailTab::Versions;
10949
10950        // Add test versions
10951        app.lambda_state.version_table.items = vec![
10952            LambdaVersion {
10953                version: "3".to_string(),
10954                aliases: "prod".to_string(),
10955                description: "".to_string(),
10956                last_modified: "".to_string(),
10957                architecture: "X86_64".to_string(),
10958            },
10959            LambdaVersion {
10960                version: "2".to_string(),
10961                aliases: "".to_string(),
10962                description: "".to_string(),
10963                last_modified: "".to_string(),
10964                architecture: "X86_64".to_string(),
10965            },
10966            LambdaVersion {
10967                version: "1".to_string(),
10968                aliases: "".to_string(),
10969                description: "".to_string(),
10970                last_modified: "".to_string(),
10971                architecture: "X86_64".to_string(),
10972            },
10973        ];
10974
10975        // Verify versions are loaded
10976        assert_eq!(app.lambda_state.version_table.items.len(), 3);
10977        assert_eq!(app.lambda_state.version_table.items[0].version, "3");
10978        assert_eq!(app.lambda_state.version_table.items[0].aliases, "prod");
10979
10980        // Verify selection can be changed
10981        app.lambda_state.version_table.selected = 1;
10982        assert_eq!(app.lambda_state.version_table.selected, 1);
10983    }
10984
10985    #[test]
10986    fn test_lambda_versions_with_aliases() {
10987        let version = LambdaVersion {
10988            version: "35".to_string(),
10989            aliases: "prod, staging".to_string(),
10990            description: "Production version".to_string(),
10991            last_modified: "2024-01-01".to_string(),
10992            architecture: "X86_64".to_string(),
10993        };
10994
10995        assert_eq!(version.aliases, "prod, staging");
10996        assert!(!version.aliases.is_empty());
10997    }
10998
10999    #[test]
11000    fn test_lambda_versions_expansion() {
11001        let mut app = test_app();
11002        app.current_service = Service::LambdaFunctions;
11003        app.service_selected = true;
11004        app.lambda_state.current_function = Some("test-function".to_string());
11005        app.lambda_state.detail_tab = LambdaDetailTab::Versions;
11006
11007        // Add test versions
11008        app.lambda_state.version_table.items = vec![
11009            LambdaVersion {
11010                version: "2".to_string(),
11011                aliases: "prod".to_string(),
11012                description: "Production".to_string(),
11013                last_modified: "2024-01-01".to_string(),
11014                architecture: "X86_64".to_string(),
11015            },
11016            LambdaVersion {
11017                version: "1".to_string(),
11018                aliases: "".to_string(),
11019                description: "".to_string(),
11020                last_modified: "2024-01-01".to_string(),
11021                architecture: "Arm64".to_string(),
11022            },
11023        ];
11024
11025        app.lambda_state.version_table.selected = 0;
11026
11027        // Verify expansion can be set
11028        app.lambda_state.version_table.expanded_item = Some(0);
11029        assert_eq!(app.lambda_state.version_table.expanded_item, Some(0));
11030
11031        // Select different version
11032        app.lambda_state.version_table.selected = 1;
11033        app.lambda_state.version_table.expanded_item = Some(1);
11034        assert_eq!(app.lambda_state.version_table.expanded_item, Some(1));
11035    }
11036
11037    #[test]
11038    fn test_lambda_versions_page_navigation() {
11039        let mut app = test_app();
11040        app.current_service = Service::LambdaFunctions;
11041        app.service_selected = true;
11042        app.lambda_state.current_function = Some("test-function".to_string());
11043        app.lambda_state.detail_tab = LambdaDetailTab::Versions;
11044
11045        // Add 30 test versions
11046        app.lambda_state.version_table.items = (1..=30)
11047            .map(|i| LambdaVersion {
11048                version: i.to_string(),
11049                aliases: "".to_string(),
11050                description: "".to_string(),
11051                last_modified: "".to_string(),
11052                architecture: "X86_64".to_string(),
11053            })
11054            .collect();
11055
11056        app.lambda_state.version_table.page_size = PageSize::Ten;
11057        app.lambda_state.version_table.selected = 0;
11058
11059        // Go to page 2
11060        app.page_input = "2".to_string();
11061        app.handle_action(Action::OpenColumnSelector);
11062
11063        // Should be at index 10 (start of page 2)
11064        assert_eq!(app.lambda_state.version_table.selected, 10);
11065    }
11066
11067    #[test]
11068    fn test_lambda_versions_pagination_arrow_keys() {
11069        let mut app = test_app();
11070        app.current_service = Service::LambdaFunctions;
11071        app.service_selected = true;
11072        app.lambda_state.current_function = Some("test-function".to_string());
11073        app.lambda_state.detail_tab = LambdaDetailTab::Versions;
11074        app.mode = Mode::FilterInput;
11075        app.lambda_state.version_input_focus = InputFocus::Pagination;
11076
11077        // Add 30 test versions
11078        app.lambda_state.version_table.items = (1..=30)
11079            .map(|i| LambdaVersion {
11080                version: i.to_string(),
11081                aliases: "".to_string(),
11082                description: "".to_string(),
11083                last_modified: "".to_string(),
11084                architecture: "X86_64".to_string(),
11085            })
11086            .collect();
11087
11088        app.lambda_state.version_table.page_size = PageSize::Ten;
11089        app.lambda_state.version_table.selected = 0;
11090
11091        // Right arrow (PageDown) should go to next page
11092        app.handle_action(Action::PageDown);
11093        assert_eq!(app.lambda_state.version_table.selected, 10);
11094
11095        // Left arrow (PageUp) should go back
11096        app.handle_action(Action::PageUp);
11097        assert_eq!(app.lambda_state.version_table.selected, 0);
11098    }
11099
11100    #[test]
11101    fn test_lambda_versions_page_input_in_filter_mode() {
11102        let mut app = test_app();
11103        app.current_service = Service::LambdaFunctions;
11104        app.service_selected = true;
11105        app.lambda_state.current_function = Some("test-function".to_string());
11106        app.lambda_state.detail_tab = LambdaDetailTab::Versions;
11107        app.mode = Mode::FilterInput;
11108        app.lambda_state.version_input_focus = InputFocus::Pagination;
11109
11110        // Add 30 test versions
11111        app.lambda_state.version_table.items = (1..=30)
11112            .map(|i| LambdaVersion {
11113                version: i.to_string(),
11114                aliases: "".to_string(),
11115                description: "".to_string(),
11116                last_modified: "".to_string(),
11117                architecture: "X86_64".to_string(),
11118            })
11119            .collect();
11120
11121        app.lambda_state.version_table.page_size = PageSize::Ten;
11122        app.lambda_state.version_table.selected = 0;
11123
11124        // Type "2" when focused on Pagination
11125        app.handle_action(Action::FilterInput('2'));
11126        assert_eq!(app.page_input, "2");
11127        assert_eq!(app.lambda_state.version_table.filter, ""); // Should not go to filter
11128
11129        // Press 'p' to go to page 2
11130        app.handle_action(Action::OpenColumnSelector);
11131        assert_eq!(app.lambda_state.version_table.selected, 10);
11132        assert_eq!(app.page_input, ""); // Should be cleared
11133    }
11134
11135    #[test]
11136    fn test_lambda_versions_filter_input() {
11137        let mut app = test_app();
11138        app.current_service = Service::LambdaFunctions;
11139        app.service_selected = true;
11140        app.lambda_state.current_function = Some("test-function".to_string());
11141        app.lambda_state.detail_tab = LambdaDetailTab::Versions;
11142        app.mode = Mode::FilterInput;
11143        app.lambda_state.version_input_focus = InputFocus::Filter;
11144
11145        // Add test versions
11146        app.lambda_state.version_table.items = vec![
11147            LambdaVersion {
11148                version: "1".to_string(),
11149                aliases: "prod".to_string(),
11150                description: "Production".to_string(),
11151                last_modified: "".to_string(),
11152                architecture: "X86_64".to_string(),
11153            },
11154            LambdaVersion {
11155                version: "2".to_string(),
11156                aliases: "staging".to_string(),
11157                description: "Staging".to_string(),
11158                last_modified: "".to_string(),
11159                architecture: "X86_64".to_string(),
11160            },
11161        ];
11162
11163        // Type filter text
11164        app.handle_action(Action::FilterInput('p'));
11165        app.handle_action(Action::FilterInput('r'));
11166        app.handle_action(Action::FilterInput('o'));
11167        app.handle_action(Action::FilterInput('d'));
11168        assert_eq!(app.lambda_state.version_table.filter, "prod");
11169
11170        // Backspace should work
11171        app.handle_action(Action::FilterBackspace);
11172        assert_eq!(app.lambda_state.version_table.filter, "pro");
11173    }
11174
11175    #[test]
11176    fn test_lambda_aliases_table_expansion() {
11177        use crate::lambda::Alias;
11178
11179        let mut app = test_app();
11180        app.current_service = Service::LambdaFunctions;
11181        app.service_selected = true;
11182        app.lambda_state.current_function = Some("test-function".to_string());
11183        app.lambda_state.detail_tab = LambdaDetailTab::Aliases;
11184        app.mode = Mode::Normal;
11185
11186        app.lambda_state.alias_table.items = vec![
11187            Alias {
11188                name: "prod".to_string(),
11189                versions: "1".to_string(),
11190                description: "Production alias".to_string(),
11191            },
11192            Alias {
11193                name: "staging".to_string(),
11194                versions: "2".to_string(),
11195                description: "Staging alias".to_string(),
11196            },
11197        ];
11198
11199        app.lambda_state.alias_table.selected = 0;
11200
11201        // Select first alias - should open alias detail view (no tab change)
11202        app.handle_action(Action::Select);
11203        assert_eq!(app.lambda_state.current_alias, Some("prod".to_string()));
11204        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Aliases);
11205
11206        // Go back
11207        app.handle_action(Action::GoBack);
11208        assert_eq!(app.lambda_state.current_alias, None);
11209        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Aliases);
11210
11211        // Select second alias
11212        app.lambda_state.alias_table.selected = 1;
11213        app.handle_action(Action::Select);
11214        assert_eq!(app.lambda_state.current_alias, Some("staging".to_string()));
11215    }
11216
11217    #[test]
11218    fn test_lambda_versions_arrow_key_expansion() {
11219        let mut app = test_app();
11220        app.current_service = Service::LambdaFunctions;
11221        app.service_selected = true;
11222        app.lambda_state.current_function = Some("test-function".to_string());
11223        app.lambda_state.detail_tab = LambdaDetailTab::Versions;
11224        app.mode = Mode::Normal;
11225
11226        app.lambda_state.version_table.items = vec![LambdaVersion {
11227            version: "1".to_string(),
11228            aliases: "prod".to_string(),
11229            description: "Production".to_string(),
11230            last_modified: "2024-01-01".to_string(),
11231            architecture: "X86_64".to_string(),
11232        }];
11233
11234        app.lambda_state.version_table.selected = 0;
11235
11236        // Right arrow expands
11237        app.handle_action(Action::NextPane);
11238        assert_eq!(app.lambda_state.version_table.expanded_item, Some(0));
11239
11240        // Left arrow collapses
11241        app.handle_action(Action::PrevPane);
11242        assert_eq!(app.lambda_state.version_table.expanded_item, None);
11243    }
11244
11245    #[test]
11246    fn test_lambda_version_detail_view() {
11247        use crate::lambda::Function;
11248
11249        let mut app = test_app();
11250        app.current_service = Service::LambdaFunctions;
11251        app.service_selected = true;
11252        app.lambda_state.current_function = Some("test-function".to_string());
11253        app.lambda_state.detail_tab = LambdaDetailTab::Versions;
11254        app.mode = Mode::Normal;
11255
11256        app.lambda_state.table.items = vec![Function {
11257            name: "test-function".to_string(),
11258            arn: "arn:aws:lambda:us-east-1:123456789012:function:test-function".to_string(),
11259            application: None,
11260            description: "Test".to_string(),
11261            package_type: "Zip".to_string(),
11262            runtime: "python3.12".to_string(),
11263            architecture: "X86_64".to_string(),
11264            code_size: 1024,
11265            code_sha256: "hash".to_string(),
11266            memory_mb: 128,
11267            timeout_seconds: 30,
11268            last_modified: "2024-01-01".to_string(),
11269            layers: vec![],
11270        }];
11271
11272        app.lambda_state.version_table.items = vec![LambdaVersion {
11273            version: "1".to_string(),
11274            aliases: "prod".to_string(),
11275            description: "Production".to_string(),
11276            last_modified: "2024-01-01".to_string(),
11277            architecture: "X86_64".to_string(),
11278        }];
11279
11280        app.lambda_state.version_table.selected = 0;
11281
11282        // Select version to open detail view
11283        app.handle_action(Action::Select);
11284        assert_eq!(app.lambda_state.current_version, Some("1".to_string()));
11285        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
11286
11287        // GoBack should go back to versions list
11288        app.handle_action(Action::GoBack);
11289        assert_eq!(app.lambda_state.current_version, None);
11290        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Versions);
11291    }
11292
11293    #[test]
11294    fn test_lambda_version_detail_tabs() {
11295        use crate::lambda::Function;
11296
11297        let mut app = test_app();
11298        app.current_service = Service::LambdaFunctions;
11299        app.service_selected = true;
11300        app.lambda_state.current_function = Some("test-function".to_string());
11301        app.lambda_state.current_version = Some("1".to_string());
11302        app.lambda_state.detail_tab = LambdaDetailTab::Code;
11303        app.mode = Mode::Normal;
11304
11305        app.lambda_state.table.items = vec![Function {
11306            name: "test-function".to_string(),
11307            arn: "arn:aws:lambda:us-east-1:123456789012:function:test-function".to_string(),
11308            application: None,
11309            description: "Test".to_string(),
11310            package_type: "Zip".to_string(),
11311            runtime: "python3.12".to_string(),
11312            architecture: "X86_64".to_string(),
11313            code_size: 1024,
11314            code_sha256: "hash".to_string(),
11315            memory_mb: 128,
11316            timeout_seconds: 30,
11317            last_modified: "2024-01-01".to_string(),
11318            layers: vec![],
11319        }];
11320
11321        // Tab should cycle between Code, Monitor, and Configuration
11322        app.handle_action(Action::NextDetailTab);
11323        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Monitor);
11324
11325        app.handle_action(Action::NextDetailTab);
11326        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Configuration);
11327
11328        app.handle_action(Action::NextDetailTab);
11329        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
11330
11331        // BackTab should cycle backward
11332        app.handle_action(Action::PrevDetailTab);
11333        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Configuration);
11334
11335        app.handle_action(Action::PrevDetailTab);
11336        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Monitor);
11337    }
11338
11339    #[test]
11340    fn test_lambda_aliases_arrow_key_expansion() {
11341        use crate::lambda::Alias;
11342
11343        let mut app = test_app();
11344        app.current_service = Service::LambdaFunctions;
11345        app.service_selected = true;
11346        app.lambda_state.current_function = Some("test-function".to_string());
11347        app.lambda_state.detail_tab = LambdaDetailTab::Aliases;
11348        app.mode = Mode::Normal;
11349
11350        app.lambda_state.alias_table.items = vec![Alias {
11351            name: "prod".to_string(),
11352            versions: "1".to_string(),
11353            description: "Production alias".to_string(),
11354        }];
11355
11356        app.lambda_state.alias_table.selected = 0;
11357
11358        // Right arrow expands
11359        app.handle_action(Action::NextPane);
11360        assert_eq!(app.lambda_state.alias_table.expanded_item, Some(0));
11361
11362        // Left arrow collapses
11363        app.handle_action(Action::PrevPane);
11364        assert_eq!(app.lambda_state.alias_table.expanded_item, None);
11365    }
11366
11367    #[test]
11368    fn test_lambda_functions_arrow_key_expansion() {
11369        use crate::lambda::Function;
11370
11371        let mut app = test_app();
11372        app.current_service = Service::LambdaFunctions;
11373        app.service_selected = true;
11374        app.mode = Mode::Normal;
11375
11376        app.lambda_state.table.items = vec![Function {
11377            name: "test-function".to_string(),
11378            arn: "arn".to_string(),
11379            application: None,
11380            description: "Test".to_string(),
11381            package_type: "Zip".to_string(),
11382            runtime: "python3.12".to_string(),
11383            architecture: "X86_64".to_string(),
11384            code_size: 1024,
11385            code_sha256: "hash".to_string(),
11386            memory_mb: 128,
11387            timeout_seconds: 30,
11388            last_modified: "2024-01-01".to_string(),
11389            layers: vec![],
11390        }];
11391
11392        app.lambda_state.table.selected = 0;
11393
11394        // Right arrow expands
11395        app.handle_action(Action::NextPane);
11396        assert_eq!(app.lambda_state.table.expanded_item, Some(0));
11397
11398        // Left arrow collapses
11399        app.handle_action(Action::PrevPane);
11400        assert_eq!(app.lambda_state.table.expanded_item, None);
11401    }
11402
11403    #[test]
11404    fn test_lambda_version_detail_with_application() {
11405        use crate::lambda::Function;
11406
11407        let mut app = test_app();
11408        app.current_service = Service::LambdaFunctions;
11409        app.service_selected = true;
11410        app.lambda_state.current_function = Some("storefront-studio-beta-api".to_string());
11411        app.lambda_state.current_version = Some("1".to_string());
11412        app.lambda_state.detail_tab = LambdaDetailTab::Code;
11413        app.mode = Mode::Normal;
11414
11415        app.lambda_state.table.items = vec![Function {
11416            name: "storefront-studio-beta-api".to_string(),
11417            arn: "arn:aws:lambda:us-east-1:123456789012:function:storefront-studio-beta-api"
11418                .to_string(),
11419            application: Some("storefront-studio-beta".to_string()),
11420            description: "API function".to_string(),
11421            package_type: "Zip".to_string(),
11422            runtime: "python3.12".to_string(),
11423            architecture: "X86_64".to_string(),
11424            code_size: 1024,
11425            code_sha256: "hash".to_string(),
11426            memory_mb: 128,
11427            timeout_seconds: 30,
11428            last_modified: "2024-01-01".to_string(),
11429            layers: vec![],
11430        }];
11431
11432        // Verify function has application extracted
11433        assert_eq!(
11434            app.lambda_state.table.items[0].application,
11435            Some("storefront-studio-beta".to_string())
11436        );
11437        assert_eq!(app.lambda_state.current_version, Some("1".to_string()));
11438    }
11439
11440    #[test]
11441    fn test_lambda_layer_navigation() {
11442        use crate::lambda::{Function, Layer};
11443
11444        let mut app = test_app();
11445        app.current_service = Service::LambdaFunctions;
11446        app.service_selected = true;
11447        app.lambda_state.current_function = Some("test-function".to_string());
11448        app.lambda_state.detail_tab = LambdaDetailTab::Code;
11449        app.mode = Mode::Normal;
11450
11451        app.lambda_state.table.items = vec![Function {
11452            name: "test-function".to_string(),
11453            arn: "arn:aws:lambda:us-east-1:123456789012:function:test-function".to_string(),
11454            application: None,
11455            description: "Test".to_string(),
11456            package_type: "Zip".to_string(),
11457            runtime: "python3.12".to_string(),
11458            architecture: "X86_64".to_string(),
11459            code_size: 1024,
11460            code_sha256: "hash".to_string(),
11461            memory_mb: 128,
11462            timeout_seconds: 30,
11463            last_modified: "2024-01-01".to_string(),
11464            layers: vec![
11465                Layer {
11466                    merge_order: "1".to_string(),
11467                    name: "layer1".to_string(),
11468                    layer_version: "1".to_string(),
11469                    compatible_runtimes: "python3.9".to_string(),
11470                    compatible_architectures: "x86_64".to_string(),
11471                    version_arn: "arn:aws:lambda:us-east-1:123456789012:layer:layer1:1".to_string(),
11472                },
11473                Layer {
11474                    merge_order: "2".to_string(),
11475                    name: "layer2".to_string(),
11476                    layer_version: "2".to_string(),
11477                    compatible_runtimes: "python3.9".to_string(),
11478                    compatible_architectures: "x86_64".to_string(),
11479                    version_arn: "arn:aws:lambda:us-east-1:123456789012:layer:layer2:2".to_string(),
11480                },
11481                Layer {
11482                    merge_order: "3".to_string(),
11483                    name: "layer3".to_string(),
11484                    layer_version: "3".to_string(),
11485                    compatible_runtimes: "python3.9".to_string(),
11486                    compatible_architectures: "x86_64".to_string(),
11487                    version_arn: "arn:aws:lambda:us-east-1:123456789012:layer:layer3:3".to_string(),
11488                },
11489            ],
11490        }];
11491
11492        assert_eq!(app.lambda_state.layer_selected, 0);
11493
11494        app.handle_action(Action::NextItem);
11495        assert_eq!(app.lambda_state.layer_selected, 1);
11496
11497        app.handle_action(Action::NextItem);
11498        assert_eq!(app.lambda_state.layer_selected, 2);
11499
11500        app.handle_action(Action::NextItem);
11501        assert_eq!(app.lambda_state.layer_selected, 2);
11502
11503        app.handle_action(Action::PrevItem);
11504        assert_eq!(app.lambda_state.layer_selected, 1);
11505
11506        app.handle_action(Action::PrevItem);
11507        assert_eq!(app.lambda_state.layer_selected, 0);
11508
11509        app.handle_action(Action::PrevItem);
11510        assert_eq!(app.lambda_state.layer_selected, 0);
11511    }
11512
11513    #[test]
11514    fn test_lambda_layer_expansion() {
11515        use crate::lambda::{Function, Layer};
11516
11517        let mut app = test_app();
11518        app.current_service = Service::LambdaFunctions;
11519        app.service_selected = true;
11520        app.lambda_state.current_function = Some("test-function".to_string());
11521        app.lambda_state.detail_tab = LambdaDetailTab::Code;
11522        app.mode = Mode::Normal;
11523
11524        app.lambda_state.table.items = vec![Function {
11525            name: "test-function".to_string(),
11526            arn: "arn:aws:lambda:us-east-1:123456789012:function:test-function".to_string(),
11527            application: None,
11528            description: "Test".to_string(),
11529            package_type: "Zip".to_string(),
11530            runtime: "python3.12".to_string(),
11531            architecture: "X86_64".to_string(),
11532            code_size: 1024,
11533            code_sha256: "hash".to_string(),
11534            memory_mb: 128,
11535            timeout_seconds: 30,
11536            last_modified: "2024-01-01".to_string(),
11537            layers: vec![Layer {
11538                merge_order: "1".to_string(),
11539                name: "test-layer".to_string(),
11540                layer_version: "1".to_string(),
11541                compatible_runtimes: "python3.9".to_string(),
11542                compatible_architectures: "x86_64".to_string(),
11543                version_arn: "arn:aws:lambda:us-east-1:123456789012:layer:test-layer:1".to_string(),
11544            }],
11545        }];
11546
11547        assert_eq!(app.lambda_state.layer_expanded, None);
11548
11549        app.handle_action(Action::NextPane);
11550        assert_eq!(app.lambda_state.layer_expanded, Some(0));
11551
11552        app.handle_action(Action::PrevPane);
11553        assert_eq!(app.lambda_state.layer_expanded, None);
11554
11555        app.handle_action(Action::NextPane);
11556        assert_eq!(app.lambda_state.layer_expanded, Some(0));
11557
11558        app.handle_action(Action::NextPane);
11559        assert_eq!(app.lambda_state.layer_expanded, None);
11560    }
11561
11562    #[test]
11563    fn test_lambda_layer_selection_and_expansion_workflow() {
11564        use crate::lambda::{Function, Layer};
11565
11566        let mut app = test_app();
11567        app.current_service = Service::LambdaFunctions;
11568        app.service_selected = true;
11569        app.lambda_state.current_function = Some("test-function".to_string());
11570        app.lambda_state.detail_tab = LambdaDetailTab::Code;
11571        app.mode = Mode::Normal;
11572
11573        app.lambda_state.table.items = vec![Function {
11574            name: "test-function".to_string(),
11575            arn: "arn:aws:lambda:us-east-1:123456789012:function:test-function".to_string(),
11576            application: None,
11577            description: "Test".to_string(),
11578            package_type: "Zip".to_string(),
11579            runtime: "python3.12".to_string(),
11580            architecture: "X86_64".to_string(),
11581            code_size: 1024,
11582            code_sha256: "hash".to_string(),
11583            memory_mb: 128,
11584            timeout_seconds: 30,
11585            last_modified: "2024-01-01".to_string(),
11586            layers: vec![
11587                Layer {
11588                    merge_order: "1".to_string(),
11589                    name: "layer1".to_string(),
11590                    layer_version: "1".to_string(),
11591                    compatible_runtimes: "python3.9".to_string(),
11592                    compatible_architectures: "x86_64".to_string(),
11593                    version_arn: "arn:aws:lambda:us-east-1:123456789012:layer:layer1:1".to_string(),
11594                },
11595                Layer {
11596                    merge_order: "2".to_string(),
11597                    name: "layer2".to_string(),
11598                    layer_version: "2".to_string(),
11599                    compatible_runtimes: "python3.9".to_string(),
11600                    compatible_architectures: "x86_64".to_string(),
11601                    version_arn: "arn:aws:lambda:us-east-1:123456789012:layer:layer2:2".to_string(),
11602                },
11603            ],
11604        }];
11605
11606        // Start at layer 0
11607        assert_eq!(app.lambda_state.layer_selected, 0);
11608        assert_eq!(app.lambda_state.layer_expanded, None);
11609
11610        // Expand layer 0
11611        app.handle_action(Action::NextPane);
11612        assert_eq!(app.lambda_state.layer_selected, 0);
11613        assert_eq!(app.lambda_state.layer_expanded, Some(0));
11614
11615        // Navigate to layer 1 while layer 0 is expanded
11616        app.handle_action(Action::NextItem);
11617        assert_eq!(app.lambda_state.layer_selected, 1);
11618        assert_eq!(app.lambda_state.layer_expanded, Some(0)); // Still expanded
11619
11620        // Expand layer 1 (should collapse layer 0 and expand layer 1)
11621        app.handle_action(Action::NextPane);
11622        assert_eq!(app.lambda_state.layer_selected, 1);
11623        assert_eq!(app.lambda_state.layer_expanded, Some(1));
11624
11625        // Collapse layer 1
11626        app.handle_action(Action::PrevPane);
11627        assert_eq!(app.lambda_state.layer_selected, 1);
11628        assert_eq!(app.lambda_state.layer_expanded, None);
11629
11630        // Navigate back to layer 0
11631        app.handle_action(Action::PrevItem);
11632        assert_eq!(app.lambda_state.layer_selected, 0);
11633        assert_eq!(app.lambda_state.layer_expanded, None);
11634    }
11635
11636    #[test]
11637    fn test_backtab_cycles_detail_tabs_backward() {
11638        let mut app = test_app();
11639        app.mode = Mode::Normal;
11640
11641        // Test Lambda detail tabs
11642        app.current_service = Service::LambdaFunctions;
11643        app.service_selected = true;
11644        app.lambda_state.current_function = Some("test-function".to_string());
11645        app.lambda_state.detail_tab = LambdaDetailTab::Code;
11646
11647        app.handle_action(Action::PrevDetailTab);
11648        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Versions);
11649
11650        app.handle_action(Action::PrevDetailTab);
11651        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Aliases);
11652
11653        // Test IAM Roles detail tabs
11654        app.current_service = Service::IamRoles;
11655        app.iam_state.current_role = Some("test-role".to_string());
11656        app.iam_state.role_tab = RoleTab::Permissions;
11657
11658        app.handle_action(Action::PrevDetailTab);
11659        assert_eq!(app.iam_state.role_tab, RoleTab::RevokeSessions);
11660
11661        // Test IAM Users detail tabs
11662        app.current_service = Service::IamUsers;
11663        app.iam_state.current_user = Some("test-user".to_string());
11664        app.iam_state.user_tab = UserTab::Permissions;
11665
11666        app.handle_action(Action::PrevDetailTab);
11667        assert_eq!(app.iam_state.user_tab, UserTab::LastAccessed);
11668
11669        // Test IAM Groups detail tabs
11670        app.current_service = Service::IamUserGroups;
11671        app.iam_state.current_group = Some("test-group".to_string());
11672        app.iam_state.group_tab = GroupTab::Permissions;
11673
11674        app.handle_action(Action::PrevDetailTab);
11675        assert_eq!(app.iam_state.group_tab, GroupTab::Users);
11676
11677        // Test S3 object tabs
11678        app.current_service = Service::S3Buckets;
11679        app.s3_state.current_bucket = Some("test-bucket".to_string());
11680        app.s3_state.object_tab = S3ObjectTab::Properties;
11681
11682        app.handle_action(Action::PrevDetailTab);
11683        assert_eq!(app.s3_state.object_tab, S3ObjectTab::Objects);
11684
11685        // Test ECR repository tabs (Private/Public)
11686        app.current_service = Service::EcrRepositories;
11687        app.ecr_state.current_repository = None;
11688        app.ecr_state.tab = EcrTab::Private;
11689
11690        app.handle_action(Action::PrevDetailTab);
11691        assert_eq!(app.ecr_state.tab, EcrTab::Public);
11692
11693        // Test CloudFormation detail tabs
11694        app.current_service = Service::CloudFormationStacks;
11695        app.cfn_state.current_stack = Some("test-stack".to_string());
11696        app.cfn_state.detail_tab = CfnDetailTab::Resources;
11697    }
11698
11699    #[test]
11700    fn test_cloudformation_status_filter_active() {
11701        let filter = CfnStatusFilter::Active;
11702        assert!(filter.matches("CREATE_IN_PROGRESS"));
11703        assert!(filter.matches("UPDATE_IN_PROGRESS"));
11704        assert!(!filter.matches("CREATE_COMPLETE"));
11705        assert!(!filter.matches("DELETE_COMPLETE"));
11706        assert!(!filter.matches("CREATE_FAILED"));
11707    }
11708
11709    #[test]
11710    fn test_cloudformation_status_filter_complete() {
11711        let filter = CfnStatusFilter::Complete;
11712        assert!(filter.matches("CREATE_COMPLETE"));
11713        assert!(filter.matches("UPDATE_COMPLETE"));
11714        assert!(!filter.matches("DELETE_COMPLETE"));
11715        assert!(!filter.matches("CREATE_IN_PROGRESS"));
11716    }
11717
11718    #[test]
11719    fn test_cloudformation_status_filter_failed() {
11720        let filter = CfnStatusFilter::Failed;
11721        assert!(filter.matches("CREATE_FAILED"));
11722        assert!(filter.matches("UPDATE_FAILED"));
11723        assert!(!filter.matches("CREATE_COMPLETE"));
11724    }
11725
11726    #[test]
11727    fn test_cloudformation_status_filter_deleted() {
11728        let filter = CfnStatusFilter::Deleted;
11729        assert!(filter.matches("DELETE_COMPLETE"));
11730        assert!(filter.matches("DELETE_IN_PROGRESS"));
11731        assert!(!filter.matches("CREATE_COMPLETE"));
11732    }
11733
11734    #[test]
11735    fn test_cloudformation_status_filter_in_progress() {
11736        let filter = CfnStatusFilter::InProgress;
11737        assert!(filter.matches("CREATE_IN_PROGRESS"));
11738        assert!(filter.matches("UPDATE_IN_PROGRESS"));
11739        assert!(filter.matches("DELETE_IN_PROGRESS"));
11740        assert!(!filter.matches("CREATE_COMPLETE"));
11741    }
11742
11743    #[test]
11744    fn test_cloudformation_status_filter_cycle() {
11745        let filter = CfnStatusFilter::All;
11746        assert_eq!(filter.next(), CfnStatusFilter::Active);
11747        assert_eq!(filter.next().next(), CfnStatusFilter::Complete);
11748        assert_eq!(filter.next().next().next(), CfnStatusFilter::Failed);
11749        assert_eq!(filter.next().next().next().next(), CfnStatusFilter::Deleted);
11750        assert_eq!(
11751            filter.next().next().next().next().next(),
11752            CfnStatusFilter::InProgress
11753        );
11754        assert_eq!(
11755            filter.next().next().next().next().next().next(),
11756            CfnStatusFilter::All
11757        );
11758    }
11759
11760    #[test]
11761    fn test_cloudformation_default_columns() {
11762        let app = test_app();
11763        assert_eq!(app.cfn_visible_column_ids.len(), 4);
11764        assert!(app.cfn_visible_column_ids.contains(&CfnColumn::Name.id()));
11765        assert!(app.cfn_visible_column_ids.contains(&CfnColumn::Status.id()));
11766        assert!(app
11767            .cfn_visible_column_ids
11768            .contains(&CfnColumn::CreatedTime.id()));
11769        assert!(app
11770            .cfn_visible_column_ids
11771            .contains(&CfnColumn::Description.id()));
11772    }
11773
11774    #[test]
11775    fn test_cloudformation_all_columns() {
11776        let app = test_app();
11777        assert_eq!(app.cfn_column_ids.len(), 10);
11778    }
11779
11780    #[test]
11781    fn test_cloudformation_filter_by_name() {
11782        let mut app = test_app();
11783        app.cfn_state.status_filter = CfnStatusFilter::Complete;
11784        app.cfn_state.table.items = vec![
11785            CfnStack {
11786                name: "my-stack".to_string(),
11787                stack_id: "id1".to_string(),
11788                status: "CREATE_COMPLETE".to_string(),
11789                created_time: "2024-01-01".to_string(),
11790                updated_time: String::new(),
11791                deleted_time: String::new(),
11792                drift_status: String::new(),
11793                last_drift_check_time: String::new(),
11794                status_reason: String::new(),
11795                description: String::new(),
11796                detailed_status: String::new(),
11797                root_stack: String::new(),
11798                parent_stack: String::new(),
11799                termination_protection: false,
11800                iam_role: String::new(),
11801                tags: Vec::new(),
11802                stack_policy: String::new(),
11803                rollback_monitoring_time: String::new(),
11804                rollback_alarms: Vec::new(),
11805                notification_arns: Vec::new(),
11806            },
11807            CfnStack {
11808                name: "other-stack".to_string(),
11809                stack_id: "id2".to_string(),
11810                status: "CREATE_COMPLETE".to_string(),
11811                created_time: "2024-01-02".to_string(),
11812                updated_time: String::new(),
11813                deleted_time: String::new(),
11814                drift_status: String::new(),
11815                last_drift_check_time: String::new(),
11816                status_reason: String::new(),
11817                description: String::new(),
11818                detailed_status: String::new(),
11819                root_stack: String::new(),
11820                parent_stack: String::new(),
11821                termination_protection: false,
11822                iam_role: String::new(),
11823                tags: Vec::new(),
11824                stack_policy: String::new(),
11825                rollback_monitoring_time: String::new(),
11826                rollback_alarms: Vec::new(),
11827                notification_arns: Vec::new(),
11828            },
11829        ];
11830
11831        app.cfn_state.table.filter = "my".to_string();
11832        let filtered = filtered_cloudformation_stacks(&app);
11833        assert_eq!(filtered.len(), 1);
11834        assert_eq!(filtered[0].name, "my-stack");
11835    }
11836
11837    #[test]
11838    fn test_cloudformation_filter_by_description() {
11839        let mut app = test_app();
11840        app.cfn_state.status_filter = CfnStatusFilter::Complete;
11841        app.cfn_state.table.items = vec![CfnStack {
11842            name: "stack1".to_string(),
11843            stack_id: "id1".to_string(),
11844            status: "CREATE_COMPLETE".to_string(),
11845            created_time: "2024-01-01".to_string(),
11846            updated_time: String::new(),
11847            deleted_time: String::new(),
11848            drift_status: String::new(),
11849            last_drift_check_time: String::new(),
11850            status_reason: String::new(),
11851            description: "production stack".to_string(),
11852            detailed_status: String::new(),
11853            root_stack: String::new(),
11854            parent_stack: String::new(),
11855            termination_protection: false,
11856            iam_role: String::new(),
11857            tags: Vec::new(),
11858            stack_policy: String::new(),
11859            rollback_monitoring_time: String::new(),
11860            rollback_alarms: Vec::new(),
11861            notification_arns: Vec::new(),
11862        }];
11863
11864        app.cfn_state.table.filter = "production".to_string();
11865        let filtered = filtered_cloudformation_stacks(&app);
11866        assert_eq!(filtered.len(), 1);
11867    }
11868
11869    #[test]
11870    fn test_cloudformation_status_filter_applied() {
11871        let mut app = test_app();
11872        app.cfn_state.table.items = vec![
11873            CfnStack {
11874                name: "complete-stack".to_string(),
11875                stack_id: "id1".to_string(),
11876                status: "CREATE_COMPLETE".to_string(),
11877                created_time: "2024-01-01".to_string(),
11878                updated_time: String::new(),
11879                deleted_time: String::new(),
11880                drift_status: String::new(),
11881                last_drift_check_time: String::new(),
11882                status_reason: String::new(),
11883                description: String::new(),
11884                detailed_status: String::new(),
11885                root_stack: String::new(),
11886                parent_stack: String::new(),
11887                termination_protection: false,
11888                iam_role: String::new(),
11889                tags: Vec::new(),
11890                stack_policy: String::new(),
11891                rollback_monitoring_time: String::new(),
11892                rollback_alarms: Vec::new(),
11893                notification_arns: Vec::new(),
11894            },
11895            CfnStack {
11896                name: "failed-stack".to_string(),
11897                stack_id: "id2".to_string(),
11898                status: "CREATE_FAILED".to_string(),
11899                created_time: "2024-01-02".to_string(),
11900                updated_time: String::new(),
11901                deleted_time: String::new(),
11902                drift_status: String::new(),
11903                last_drift_check_time: String::new(),
11904                status_reason: String::new(),
11905                description: String::new(),
11906                detailed_status: String::new(),
11907                root_stack: String::new(),
11908                parent_stack: String::new(),
11909                termination_protection: false,
11910                iam_role: String::new(),
11911                tags: Vec::new(),
11912                stack_policy: String::new(),
11913                rollback_monitoring_time: String::new(),
11914                rollback_alarms: Vec::new(),
11915                notification_arns: Vec::new(),
11916            },
11917        ];
11918
11919        app.cfn_state.status_filter = CfnStatusFilter::Complete;
11920        let filtered = filtered_cloudformation_stacks(&app);
11921        assert_eq!(filtered.len(), 1);
11922        assert_eq!(filtered[0].name, "complete-stack");
11923
11924        app.cfn_state.status_filter = CfnStatusFilter::Failed;
11925        let filtered = filtered_cloudformation_stacks(&app);
11926        assert_eq!(filtered.len(), 1);
11927        assert_eq!(filtered[0].name, "failed-stack");
11928    }
11929
11930    #[test]
11931    fn test_cloudformation_default_page_size() {
11932        let app = test_app();
11933        assert_eq!(app.cfn_state.table.page_size, PageSize::Fifty);
11934    }
11935
11936    #[test]
11937    fn test_cloudformation_default_status_filter() {
11938        let app = test_app();
11939        assert_eq!(app.cfn_state.status_filter, CfnStatusFilter::All);
11940    }
11941
11942    #[test]
11943    fn test_cloudformation_view_nested_default_false() {
11944        let app = test_app();
11945        assert!(!app.cfn_state.view_nested);
11946    }
11947
11948    #[test]
11949    fn test_cloudformation_pagination_hotkeys() {
11950        let mut app = test_app();
11951        app.current_service = Service::CloudFormationStacks;
11952        app.service_selected = true;
11953        app.cfn_state.status_filter = CfnStatusFilter::All;
11954
11955        // Add 150 stacks
11956        for i in 0..150 {
11957            app.cfn_state.table.items.push(CfnStack {
11958                name: format!("stack-{}", i),
11959                stack_id: format!("id-{}", i),
11960                status: "CREATE_COMPLETE".to_string(),
11961                created_time: "2025-01-01 00:00:00 (UTC)".to_string(),
11962                updated_time: String::new(),
11963                deleted_time: String::new(),
11964                drift_status: String::new(),
11965                last_drift_check_time: String::new(),
11966                status_reason: String::new(),
11967                description: String::new(),
11968                detailed_status: String::new(),
11969                root_stack: String::new(),
11970                parent_stack: String::new(),
11971                termination_protection: false,
11972                iam_role: String::new(),
11973                tags: vec![],
11974                stack_policy: String::new(),
11975                rollback_monitoring_time: String::new(),
11976                rollback_alarms: vec![],
11977                notification_arns: vec![],
11978            });
11979        }
11980
11981        // Go to page 2
11982        app.go_to_page(2);
11983        assert_eq!(app.cfn_state.table.selected, 50);
11984
11985        // Go to page 3
11986        app.go_to_page(3);
11987        assert_eq!(app.cfn_state.table.selected, 100);
11988
11989        // Go to page 1
11990        app.go_to_page(1);
11991        assert_eq!(app.cfn_state.table.selected, 0);
11992    }
11993
11994    #[test]
11995    fn test_cloudformation_tab_cycling_in_filter_mode() {
11996        use crate::ui::cfn::{STATUS_FILTER, VIEW_NESTED};
11997        let mut app = test_app();
11998        app.current_service = Service::CloudFormationStacks;
11999        app.service_selected = true;
12000        app.mode = Mode::FilterInput;
12001        app.cfn_state.input_focus = InputFocus::Filter;
12002
12003        // Tab to StatusFilter
12004        app.handle_action(Action::NextFilterFocus);
12005        assert_eq!(app.cfn_state.input_focus, STATUS_FILTER);
12006
12007        // Tab to ViewNested
12008        app.handle_action(Action::NextFilterFocus);
12009        assert_eq!(app.cfn_state.input_focus, VIEW_NESTED);
12010
12011        // Tab to Pagination
12012        app.handle_action(Action::NextFilterFocus);
12013        assert_eq!(app.cfn_state.input_focus, InputFocus::Pagination);
12014
12015        // Tab back to Filter
12016        app.handle_action(Action::NextFilterFocus);
12017        assert_eq!(app.cfn_state.input_focus, InputFocus::Filter);
12018    }
12019
12020    #[test]
12021    fn test_cloudformation_timestamp_format_includes_utc() {
12022        let stack = CfnStack {
12023            name: "test-stack".to_string(),
12024            stack_id: "id-123".to_string(),
12025            status: "CREATE_COMPLETE".to_string(),
12026            created_time: "2025-08-07 15:38:02 (UTC)".to_string(),
12027            updated_time: "2025-08-08 10:00:00 (UTC)".to_string(),
12028            deleted_time: String::new(),
12029            drift_status: String::new(),
12030            last_drift_check_time: "2025-08-09 12:00:00 (UTC)".to_string(),
12031            status_reason: String::new(),
12032            description: String::new(),
12033            detailed_status: String::new(),
12034            root_stack: String::new(),
12035            parent_stack: String::new(),
12036            termination_protection: false,
12037            iam_role: String::new(),
12038            tags: vec![],
12039            stack_policy: String::new(),
12040            rollback_monitoring_time: String::new(),
12041            rollback_alarms: vec![],
12042            notification_arns: vec![],
12043        };
12044
12045        assert!(stack.created_time.contains("(UTC)"));
12046        assert!(stack.updated_time.contains("(UTC)"));
12047        assert!(stack.last_drift_check_time.contains("(UTC)"));
12048        assert_eq!(stack.created_time.len(), 25);
12049    }
12050
12051    #[test]
12052    fn test_cloudformation_enter_drills_into_stack_view() {
12053        let mut app = test_app();
12054        app.current_service = Service::CloudFormationStacks;
12055        app.service_selected = true;
12056        app.mode = Mode::Normal;
12057        app.cfn_state.status_filter = CfnStatusFilter::All;
12058        app.tabs = vec![Tab {
12059            service: Service::CloudFormationStacks,
12060            title: "CloudFormation › Stacks".to_string(),
12061            breadcrumb: "CloudFormation › Stacks".to_string(),
12062        }];
12063        app.current_tab = 0;
12064
12065        app.cfn_state.table.items.push(CfnStack {
12066            name: "test-stack".to_string(),
12067            stack_id: "id-123".to_string(),
12068            status: "CREATE_COMPLETE".to_string(),
12069            created_time: "2025-01-01 00:00:00 (UTC)".to_string(),
12070            updated_time: String::new(),
12071            deleted_time: String::new(),
12072            drift_status: String::new(),
12073            last_drift_check_time: String::new(),
12074            status_reason: String::new(),
12075            description: String::new(),
12076            detailed_status: String::new(),
12077            root_stack: String::new(),
12078            parent_stack: String::new(),
12079            termination_protection: false,
12080            iam_role: String::new(),
12081            tags: vec![],
12082            stack_policy: String::new(),
12083            rollback_monitoring_time: String::new(),
12084            rollback_alarms: vec![],
12085            notification_arns: vec![],
12086        });
12087
12088        app.cfn_state.table.reset();
12089        assert_eq!(app.cfn_state.current_stack, None);
12090
12091        // Press Enter - should drill into stack detail view
12092        app.handle_action(Action::Select);
12093        assert_eq!(app.cfn_state.current_stack, Some("test-stack".to_string()));
12094    }
12095
12096    #[test]
12097    fn test_cloudformation_arrow_keys_expand_collapse() {
12098        let mut app = test_app();
12099        app.current_service = Service::CloudFormationStacks;
12100        app.service_selected = true;
12101        app.mode = Mode::Normal;
12102        app.cfn_state.status_filter = CfnStatusFilter::All;
12103
12104        app.cfn_state.table.items.push(CfnStack {
12105            name: "test-stack".to_string(),
12106            stack_id: "id-123".to_string(),
12107            status: "CREATE_COMPLETE".to_string(),
12108            created_time: "2025-01-01 00:00:00 (UTC)".to_string(),
12109            updated_time: String::new(),
12110            deleted_time: String::new(),
12111            drift_status: String::new(),
12112            last_drift_check_time: String::new(),
12113            status_reason: String::new(),
12114            description: String::new(),
12115            detailed_status: String::new(),
12116            root_stack: String::new(),
12117            parent_stack: String::new(),
12118            termination_protection: false,
12119            iam_role: String::new(),
12120            tags: vec![],
12121            stack_policy: String::new(),
12122            rollback_monitoring_time: String::new(),
12123            rollback_alarms: vec![],
12124            notification_arns: vec![],
12125        });
12126
12127        app.cfn_state.table.reset();
12128        assert_eq!(app.cfn_state.table.expanded_item, None);
12129
12130        // Right arrow - should expand
12131        app.handle_action(Action::NextPane);
12132        assert_eq!(app.cfn_state.table.expanded_item, Some(0));
12133
12134        // Left arrow - should collapse
12135        app.handle_action(Action::PrevPane);
12136        assert_eq!(app.cfn_state.table.expanded_item, None);
12137
12138        // Verify current_stack is still None (not drilled in)
12139        assert_eq!(app.cfn_state.current_stack, None);
12140    }
12141
12142    #[test]
12143    fn test_cloudformation_tab_cycling() {
12144        use crate::ui::cfn::DetailTab;
12145        let mut app = test_app();
12146        app.current_service = Service::CloudFormationStacks;
12147        app.service_selected = true;
12148        app.mode = Mode::Normal;
12149        app.cfn_state.status_filter = CfnStatusFilter::All;
12150        app.cfn_state.current_stack = Some("test-stack".to_string());
12151
12152        assert_eq!(app.cfn_state.detail_tab, DetailTab::StackInfo);
12153    }
12154
12155    #[test]
12156    fn test_cloudformation_console_url() {
12157        use crate::ui::cfn::DetailTab;
12158        let mut app = test_app();
12159        app.current_service = Service::CloudFormationStacks;
12160        app.service_selected = true;
12161        app.cfn_state.status_filter = CfnStatusFilter::All;
12162
12163        app.cfn_state.table.items.push(CfnStack {
12164            name: "test-stack".to_string(),
12165            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
12166                .to_string(),
12167            status: "CREATE_COMPLETE".to_string(),
12168            created_time: "2025-01-01 00:00:00 (UTC)".to_string(),
12169            updated_time: String::new(),
12170            deleted_time: String::new(),
12171            drift_status: String::new(),
12172            last_drift_check_time: String::new(),
12173            status_reason: String::new(),
12174            description: String::new(),
12175            detailed_status: String::new(),
12176            root_stack: String::new(),
12177            parent_stack: String::new(),
12178            termination_protection: false,
12179            iam_role: String::new(),
12180            tags: vec![],
12181            stack_policy: String::new(),
12182            rollback_monitoring_time: String::new(),
12183            rollback_alarms: vec![],
12184            notification_arns: vec![],
12185        });
12186
12187        app.cfn_state.current_stack = Some("test-stack".to_string());
12188
12189        // Stack info URL
12190        app.cfn_state.detail_tab = DetailTab::StackInfo;
12191        let url = app.get_console_url();
12192        assert!(url.contains("stackinfo"));
12193        assert!(url.contains("arn%3Aaws%3Acloudformation"));
12194
12195        // Events URL
12196        app.cfn_state.detail_tab = DetailTab::Events;
12197        let url = app.get_console_url();
12198        assert!(url.contains("events"));
12199        assert!(url.contains("arn%3Aaws%3Acloudformation"));
12200    }
12201
12202    #[test]
12203    fn test_iam_role_select() {
12204        let mut app = test_app();
12205        app.current_service = Service::IamRoles;
12206        app.service_selected = true;
12207        app.mode = Mode::Normal;
12208
12209        app.iam_state.roles.items = vec![
12210            IamRole {
12211                role_name: "role1".to_string(),
12212                path: "/".to_string(),
12213                trusted_entities: "AWS Service: ec2".to_string(),
12214                last_activity: "-".to_string(),
12215                arn: "arn:aws:iam::123456789012:role/role1".to_string(),
12216                creation_time: "2025-01-01".to_string(),
12217                description: "Test role 1".to_string(),
12218                max_session_duration: Some(3600),
12219            },
12220            IamRole {
12221                role_name: "role2".to_string(),
12222                path: "/".to_string(),
12223                trusted_entities: "AWS Service: lambda".to_string(),
12224                last_activity: "-".to_string(),
12225                arn: "arn:aws:iam::123456789012:role/role2".to_string(),
12226                creation_time: "2025-01-02".to_string(),
12227                description: "Test role 2".to_string(),
12228                max_session_duration: Some(7200),
12229            },
12230        ];
12231
12232        // Select first role
12233        app.iam_state.roles.selected = 0;
12234        app.handle_action(Action::Select);
12235
12236        assert_eq!(
12237            app.iam_state.current_role,
12238            Some("role1".to_string()),
12239            "Should open role detail view"
12240        );
12241        assert_eq!(
12242            app.iam_state.role_tab,
12243            RoleTab::Permissions,
12244            "Should default to Permissions tab"
12245        );
12246    }
12247
12248    #[test]
12249    fn test_iam_role_back_navigation() {
12250        let mut app = test_app();
12251        app.current_service = Service::IamRoles;
12252        app.service_selected = true;
12253        app.iam_state.current_role = Some("test-role".to_string());
12254
12255        app.handle_action(Action::GoBack);
12256
12257        assert_eq!(
12258            app.iam_state.current_role, None,
12259            "Should return to roles list"
12260        );
12261    }
12262
12263    #[test]
12264    fn test_iam_role_tab_navigation() {
12265        let mut app = test_app();
12266        app.current_service = Service::IamRoles;
12267        app.service_selected = true;
12268        app.iam_state.current_role = Some("test-role".to_string());
12269        app.iam_state.role_tab = RoleTab::Permissions;
12270
12271        app.handle_action(Action::NextDetailTab);
12272
12273        assert_eq!(
12274            app.iam_state.role_tab,
12275            RoleTab::TrustRelationships,
12276            "Should move to next tab"
12277        );
12278    }
12279
12280    #[test]
12281    fn test_iam_role_tab_cycle_order() {
12282        let mut app = test_app();
12283        app.current_service = Service::IamRoles;
12284        app.service_selected = true;
12285        app.iam_state.current_role = Some("test-role".to_string());
12286        app.iam_state.role_tab = RoleTab::Permissions;
12287
12288        app.handle_action(Action::NextDetailTab);
12289        assert_eq!(app.iam_state.role_tab, RoleTab::TrustRelationships);
12290
12291        app.handle_action(Action::NextDetailTab);
12292        assert_eq!(app.iam_state.role_tab, RoleTab::Tags);
12293
12294        app.handle_action(Action::NextDetailTab);
12295        assert_eq!(app.iam_state.role_tab, RoleTab::LastAccessed);
12296
12297        app.handle_action(Action::NextDetailTab);
12298        assert_eq!(app.iam_state.role_tab, RoleTab::RevokeSessions);
12299
12300        app.handle_action(Action::NextDetailTab);
12301        assert_eq!(
12302            app.iam_state.role_tab,
12303            RoleTab::Permissions,
12304            "Should cycle back to first tab"
12305        );
12306    }
12307
12308    #[test]
12309    fn test_iam_role_pagination() {
12310        let mut app = test_app();
12311        app.current_service = Service::IamRoles;
12312        app.service_selected = true;
12313        app.iam_state.roles.page_size = PageSize::Ten;
12314
12315        app.iam_state.roles.items = (0..25)
12316            .map(|i| IamRole {
12317                role_name: format!("role{}", i),
12318                path: "/".to_string(),
12319                trusted_entities: "AWS Service: ec2".to_string(),
12320                last_activity: "-".to_string(),
12321                arn: format!("arn:aws:iam::123456789012:role/role{}", i),
12322                creation_time: "2025-01-01".to_string(),
12323                description: format!("Test role {}", i),
12324                max_session_duration: Some(3600),
12325            })
12326            .collect();
12327
12328        // Jump to page 2
12329        app.go_to_page(2);
12330
12331        assert_eq!(
12332            app.iam_state.roles.selected, 10,
12333            "Should select first item of page 2"
12334        );
12335        assert_eq!(
12336            app.iam_state.roles.scroll_offset, 10,
12337            "Should update scroll offset"
12338        );
12339    }
12340
12341    #[test]
12342    fn test_tags_table_populated_on_role_detail() {
12343        let mut app = test_app();
12344        app.current_service = Service::IamRoles;
12345        app.service_selected = true;
12346        app.mode = Mode::Normal;
12347        app.iam_state.roles.items = vec![IamRole {
12348            role_name: "TestRole".to_string(),
12349            path: "/".to_string(),
12350            trusted_entities: String::new(),
12351            last_activity: String::new(),
12352            arn: "arn:aws:iam::123456789012:role/TestRole".to_string(),
12353            creation_time: "2025-01-01".to_string(),
12354            description: String::new(),
12355            max_session_duration: Some(3600),
12356        }];
12357
12358        // Manually populate tags to test table rendering
12359        app.iam_state.tags.items = vec![
12360            IamRoleTag {
12361                key: "Environment".to_string(),
12362                value: "Production".to_string(),
12363            },
12364            IamRoleTag {
12365                key: "Team".to_string(),
12366                value: "Platform".to_string(),
12367            },
12368        ];
12369
12370        assert_eq!(app.iam_state.tags.items.len(), 2);
12371        assert_eq!(app.iam_state.tags.items[0].key, "Environment");
12372        assert_eq!(app.iam_state.tags.items[0].value, "Production");
12373        assert_eq!(app.iam_state.tags.selected, 0);
12374    }
12375
12376    #[test]
12377    fn test_tags_table_navigation() {
12378        let mut app = test_app();
12379        app.current_service = Service::IamRoles;
12380        app.service_selected = true;
12381        app.mode = Mode::Normal;
12382        app.iam_state.current_role = Some("TestRole".to_string());
12383        app.iam_state.role_tab = RoleTab::Tags;
12384        app.iam_state.tags.items = vec![
12385            IamRoleTag {
12386                key: "Tag1".to_string(),
12387                value: "Value1".to_string(),
12388            },
12389            IamRoleTag {
12390                key: "Tag2".to_string(),
12391                value: "Value2".to_string(),
12392            },
12393        ];
12394
12395        app.handle_action(Action::NextItem);
12396        assert_eq!(app.iam_state.tags.selected, 1);
12397
12398        app.handle_action(Action::PrevItem);
12399        assert_eq!(app.iam_state.tags.selected, 0);
12400    }
12401
12402    #[test]
12403    fn test_last_accessed_table_navigation() {
12404        let mut app = test_app();
12405        app.current_service = Service::IamRoles;
12406        app.service_selected = true;
12407        app.mode = Mode::Normal;
12408        app.iam_state.current_role = Some("TestRole".to_string());
12409        app.iam_state.role_tab = RoleTab::LastAccessed;
12410        app.iam_state.last_accessed_services.items = vec![
12411            LastAccessedService {
12412                service: "S3".to_string(),
12413                policies_granting: "Policy1".to_string(),
12414                last_accessed: "2025-01-01".to_string(),
12415            },
12416            LastAccessedService {
12417                service: "EC2".to_string(),
12418                policies_granting: "Policy2".to_string(),
12419                last_accessed: "2025-01-02".to_string(),
12420            },
12421        ];
12422
12423        app.handle_action(Action::NextItem);
12424        assert_eq!(app.iam_state.last_accessed_services.selected, 1);
12425
12426        app.handle_action(Action::PrevItem);
12427        assert_eq!(app.iam_state.last_accessed_services.selected, 0);
12428    }
12429
12430    #[test]
12431    fn test_cfn_input_focus_next() {
12432        use crate::ui::cfn::{STATUS_FILTER, VIEW_NESTED};
12433        let mut app = test_app();
12434        app.current_service = Service::CloudFormationStacks;
12435        app.mode = Mode::FilterInput;
12436        app.cfn_state.input_focus = InputFocus::Filter;
12437
12438        app.handle_action(Action::NextFilterFocus);
12439        assert_eq!(app.cfn_state.input_focus, STATUS_FILTER);
12440
12441        app.handle_action(Action::NextFilterFocus);
12442        assert_eq!(app.cfn_state.input_focus, VIEW_NESTED);
12443
12444        app.handle_action(Action::NextFilterFocus);
12445        assert_eq!(app.cfn_state.input_focus, InputFocus::Pagination);
12446
12447        app.handle_action(Action::NextFilterFocus);
12448        assert_eq!(app.cfn_state.input_focus, InputFocus::Filter);
12449    }
12450
12451    #[test]
12452    fn test_cfn_input_focus_prev() {
12453        use crate::ui::cfn::{STATUS_FILTER, VIEW_NESTED};
12454        let mut app = test_app();
12455        app.current_service = Service::CloudFormationStacks;
12456        app.mode = Mode::FilterInput;
12457        app.cfn_state.input_focus = InputFocus::Filter;
12458
12459        app.handle_action(Action::PrevFilterFocus);
12460        assert_eq!(app.cfn_state.input_focus, InputFocus::Pagination);
12461
12462        app.handle_action(Action::PrevFilterFocus);
12463        assert_eq!(app.cfn_state.input_focus, VIEW_NESTED);
12464
12465        app.handle_action(Action::PrevFilterFocus);
12466        assert_eq!(app.cfn_state.input_focus, STATUS_FILTER);
12467
12468        app.handle_action(Action::PrevFilterFocus);
12469        assert_eq!(app.cfn_state.input_focus, InputFocus::Filter);
12470    }
12471
12472    #[test]
12473    fn test_cw_logs_input_focus_prev() {
12474        let mut app = test_app();
12475        app.current_service = Service::CloudWatchLogGroups;
12476        app.mode = Mode::FilterInput;
12477        app.view_mode = ViewMode::Detail;
12478        app.log_groups_state.detail_tab = CwLogsDetailTab::LogStreams;
12479        app.log_groups_state.input_focus = InputFocus::Filter;
12480
12481        app.handle_action(Action::PrevFilterFocus);
12482        assert_eq!(app.log_groups_state.input_focus, InputFocus::Pagination);
12483
12484        app.handle_action(Action::PrevFilterFocus);
12485        assert_eq!(
12486            app.log_groups_state.input_focus,
12487            InputFocus::Checkbox("ShowExpired")
12488        );
12489
12490        app.handle_action(Action::PrevFilterFocus);
12491        assert_eq!(
12492            app.log_groups_state.input_focus,
12493            InputFocus::Checkbox("ExactMatch")
12494        );
12495
12496        app.handle_action(Action::PrevFilterFocus);
12497        assert_eq!(app.log_groups_state.input_focus, InputFocus::Filter);
12498    }
12499
12500    #[test]
12501    fn test_cw_events_input_focus_prev() {
12502        use crate::ui::cw::logs::EventFilterFocus;
12503        let mut app = test_app();
12504        app.mode = Mode::EventFilterInput;
12505        app.log_groups_state.event_input_focus = EventFilterFocus::Filter;
12506
12507        app.handle_action(Action::PrevFilterFocus);
12508        assert_eq!(
12509            app.log_groups_state.event_input_focus,
12510            EventFilterFocus::DateRange
12511        );
12512
12513        app.handle_action(Action::PrevFilterFocus);
12514        assert_eq!(
12515            app.log_groups_state.event_input_focus,
12516            EventFilterFocus::Filter
12517        );
12518    }
12519
12520    #[test]
12521    fn test_cfn_input_focus_cycle_complete() {
12522        let mut app = test_app();
12523        app.current_service = Service::CloudFormationStacks;
12524        app.mode = Mode::FilterInput;
12525        app.cfn_state.input_focus = InputFocus::Filter;
12526
12527        // Cycle forward through all controls
12528        for _ in 0..4 {
12529            app.handle_action(Action::NextFilterFocus);
12530        }
12531        assert_eq!(app.cfn_state.input_focus, InputFocus::Filter);
12532
12533        // Cycle backward through all controls
12534        for _ in 0..4 {
12535            app.handle_action(Action::PrevFilterFocus);
12536        }
12537        assert_eq!(app.cfn_state.input_focus, InputFocus::Filter);
12538    }
12539
12540    #[test]
12541    fn test_cfn_resources_pagination_left_right_arrows() {
12542        // Regression: Resources tab has a Pagination focus in filter controls,
12543        // but PageDown/PageUp in FilterInput mode didn't handle Resources —
12544        // they fell through to the stacks table instead.
12545        let mut app = test_app();
12546        app.current_service = Service::CloudFormationStacks;
12547        app.service_selected = true;
12548        app.mode = Mode::FilterInput;
12549        app.cfn_state.current_stack = Some("test-stack".to_string());
12550        app.cfn_state.detail_tab = CfnDetailTab::Resources;
12551        app.cfn_state.resources_input_focus = InputFocus::Pagination;
12552
12553        // 60 resources with page size 10 = 6 pages
12554        app.cfn_state.resources.items = (0..60)
12555            .map(|i| rusticity_core::cfn::StackResource {
12556                logical_id: format!("Resource{}", i),
12557                physical_id: format!("phys{}", i),
12558                resource_type: "AWS::S3::Bucket".to_string(),
12559                status: String::new(),
12560                module_info: String::new(),
12561            })
12562            .collect();
12563        app.cfn_state.resources.page_size = crate::common::PageSize::Ten;
12564        app.cfn_state.resources.selected = 0;
12565
12566        // Right arrow (PageDown) must go to page 2
12567        app.handle_action(Action::PageDown);
12568        assert_eq!(
12569            app.cfn_state.resources.selected, 10,
12570            "Right arrow with pagination focus must move to page 2 of Resources"
12571        );
12572
12573        // Left arrow (PageUp) must go back to page 1
12574        app.handle_action(Action::PageUp);
12575        assert_eq!(
12576            app.cfn_state.resources.selected, 0,
12577            "Left arrow with pagination focus must move back to page 1 of Resources"
12578        );
12579    }
12580
12581    #[test]
12582    fn test_cfn_filter_status_arrow_keys() {
12583        use crate::ui::cfn::STATUS_FILTER;
12584        let mut app = test_app();
12585        app.current_service = Service::CloudFormationStacks;
12586        app.mode = Mode::FilterInput;
12587        app.cfn_state.input_focus = STATUS_FILTER;
12588        app.cfn_state.status_filter = CfnStatusFilter::All;
12589
12590        app.handle_action(Action::NextItem);
12591        assert_eq!(app.cfn_state.status_filter, CfnStatusFilter::Active);
12592
12593        app.handle_action(Action::PrevItem);
12594        assert_eq!(app.cfn_state.status_filter, CfnStatusFilter::All);
12595    }
12596
12597    #[test]
12598    fn test_cfn_filter_shift_tab_cycles_backward() {
12599        use crate::ui::cfn::STATUS_FILTER;
12600        let mut app = test_app();
12601        app.current_service = Service::CloudFormationStacks;
12602        app.mode = Mode::FilterInput;
12603        app.cfn_state.input_focus = STATUS_FILTER;
12604
12605        // Shift+Tab should go backward
12606        app.handle_action(Action::PrevFilterFocus);
12607        assert_eq!(app.cfn_state.input_focus, InputFocus::Filter);
12608
12609        // From Input, Shift+Tab should wrap to Pagination
12610        app.handle_action(Action::PrevFilterFocus);
12611        assert_eq!(app.cfn_state.input_focus, InputFocus::Pagination);
12612    }
12613
12614    #[test]
12615    fn test_cfn_pagination_arrow_keys() {
12616        let mut app = test_app();
12617        app.current_service = Service::CloudFormationStacks;
12618        app.mode = Mode::FilterInput;
12619        app.cfn_state.input_focus = InputFocus::Pagination;
12620        app.cfn_state.table.scroll_offset = 0;
12621        app.cfn_state.table.page_size = PageSize::Ten;
12622
12623        // Add some test stacks
12624        app.cfn_state.table.items = (0..30)
12625            .map(|i| CfnStack {
12626                name: format!("stack-{}", i),
12627                stack_id: format!("id-{}", i),
12628                status: "CREATE_COMPLETE".to_string(),
12629                created_time: "2024-01-01".to_string(),
12630                updated_time: String::new(),
12631                deleted_time: String::new(),
12632                drift_status: String::new(),
12633                last_drift_check_time: String::new(),
12634                status_reason: String::new(),
12635                description: String::new(),
12636                detailed_status: String::new(),
12637                root_stack: String::new(),
12638                parent_stack: String::new(),
12639                termination_protection: false,
12640                iam_role: String::new(),
12641                tags: Vec::new(),
12642                stack_policy: String::new(),
12643                rollback_monitoring_time: String::new(),
12644                rollback_alarms: Vec::new(),
12645                notification_arns: Vec::new(),
12646            })
12647            .collect();
12648
12649        // Right arrow should page forward
12650        app.handle_action(Action::PageDown);
12651        assert_eq!(app.cfn_state.table.scroll_offset, 10);
12652        // Verify page number calculation
12653        let page_size = app.cfn_state.table.page_size.value();
12654        let current_page = app.cfn_state.table.scroll_offset / page_size;
12655        assert_eq!(current_page, 1);
12656
12657        // Left arrow should page backward
12658        app.handle_action(Action::PageUp);
12659        assert_eq!(app.cfn_state.table.scroll_offset, 0);
12660        let current_page = app.cfn_state.table.scroll_offset / page_size;
12661        assert_eq!(current_page, 0);
12662    }
12663
12664    #[test]
12665    fn test_cfn_page_navigation_updates_selection() {
12666        let mut app = test_app();
12667        app.current_service = Service::CloudFormationStacks;
12668        app.mode = Mode::Normal;
12669
12670        // Add 30 test stacks
12671        app.cfn_state.table.items = (0..30)
12672            .map(|i| CfnStack {
12673                name: format!("stack-{}", i),
12674                stack_id: format!("id-{}", i),
12675                status: "CREATE_COMPLETE".to_string(),
12676                created_time: "2024-01-01".to_string(),
12677                updated_time: String::new(),
12678                deleted_time: String::new(),
12679                drift_status: String::new(),
12680                last_drift_check_time: String::new(),
12681                status_reason: String::new(),
12682                description: String::new(),
12683                detailed_status: String::new(),
12684                root_stack: String::new(),
12685                parent_stack: String::new(),
12686                termination_protection: false,
12687                iam_role: String::new(),
12688                tags: Vec::new(),
12689                stack_policy: String::new(),
12690                rollback_monitoring_time: String::new(),
12691                rollback_alarms: Vec::new(),
12692                notification_arns: Vec::new(),
12693            })
12694            .collect();
12695
12696        app.cfn_state.table.reset();
12697        app.cfn_state.table.scroll_offset = 0;
12698
12699        // Page down should update selection
12700        app.handle_action(Action::PageDown);
12701        assert_eq!(app.cfn_state.table.selected, 10);
12702
12703        // Page down again
12704        app.handle_action(Action::PageDown);
12705        assert_eq!(app.cfn_state.table.selected, 20);
12706
12707        // Page up should update selection
12708        app.handle_action(Action::PageUp);
12709        assert_eq!(app.cfn_state.table.selected, 10);
12710    }
12711
12712    #[test]
12713    fn test_cfn_filter_input_only_when_focused() {
12714        use crate::ui::cfn::STATUS_FILTER;
12715        let mut app = test_app();
12716        app.current_service = Service::CloudFormationStacks;
12717        app.mode = Mode::FilterInput;
12718        app.cfn_state.input_focus = STATUS_FILTER;
12719        app.cfn_state.table.filter = String::new();
12720
12721        // Typing should not add to filter when focus is not on Input
12722        app.handle_action(Action::FilterInput('t'));
12723        app.handle_action(Action::FilterInput('e'));
12724        app.handle_action(Action::FilterInput('s'));
12725        app.handle_action(Action::FilterInput('t'));
12726        assert_eq!(app.cfn_state.table.filter, "");
12727
12728        // Switch to Input focus
12729        app.cfn_state.input_focus = InputFocus::Filter;
12730        app.handle_action(Action::FilterInput('t'));
12731        app.handle_action(Action::FilterInput('e'));
12732        app.handle_action(Action::FilterInput('s'));
12733        app.handle_action(Action::FilterInput('t'));
12734        assert_eq!(app.cfn_state.table.filter, "test");
12735    }
12736
12737    #[test]
12738    fn test_cfn_input_focus_resets_on_start() {
12739        let mut app = test_app();
12740        app.current_service = Service::CloudFormationStacks;
12741        app.service_selected = true;
12742        app.mode = Mode::Normal;
12743        app.cfn_state.input_focus = InputFocus::Pagination;
12744
12745        // Start filter should reset focus to Input
12746        app.handle_action(Action::StartFilter);
12747        assert_eq!(app.mode, Mode::FilterInput);
12748        assert_eq!(app.cfn_state.input_focus, InputFocus::Filter);
12749    }
12750
12751    #[test]
12752    fn test_iam_roles_input_focus_cycles_forward() {
12753        let mut app = test_app();
12754        app.current_service = Service::IamRoles;
12755        app.mode = Mode::FilterInput;
12756        app.iam_state.role_input_focus = InputFocus::Filter;
12757
12758        app.handle_action(Action::NextFilterFocus);
12759        assert_eq!(app.iam_state.role_input_focus, InputFocus::Pagination);
12760
12761        app.handle_action(Action::NextFilterFocus);
12762        assert_eq!(app.iam_state.role_input_focus, InputFocus::Filter);
12763    }
12764
12765    #[test]
12766    fn test_iam_roles_input_focus_cycles_backward() {
12767        let mut app = test_app();
12768        app.current_service = Service::IamRoles;
12769        app.mode = Mode::FilterInput;
12770        app.iam_state.role_input_focus = InputFocus::Filter;
12771
12772        app.handle_action(Action::PrevFilterFocus);
12773        assert_eq!(app.iam_state.role_input_focus, InputFocus::Pagination);
12774
12775        app.handle_action(Action::PrevFilterFocus);
12776        assert_eq!(app.iam_state.role_input_focus, InputFocus::Filter);
12777    }
12778
12779    #[test]
12780    fn test_iam_roles_filter_input_only_when_focused() {
12781        let mut app = test_app();
12782        app.current_service = Service::IamRoles;
12783        app.mode = Mode::FilterInput;
12784        app.iam_state.role_input_focus = InputFocus::Pagination;
12785        app.iam_state.roles.filter = String::new();
12786
12787        // Typing should not add to filter when focus is on Pagination
12788        app.handle_action(Action::FilterInput('t'));
12789        app.handle_action(Action::FilterInput('e'));
12790        app.handle_action(Action::FilterInput('s'));
12791        app.handle_action(Action::FilterInput('t'));
12792        assert_eq!(app.iam_state.roles.filter, "");
12793
12794        // Switch to Input focus
12795        app.iam_state.role_input_focus = InputFocus::Filter;
12796        app.handle_action(Action::FilterInput('t'));
12797        app.handle_action(Action::FilterInput('e'));
12798        app.handle_action(Action::FilterInput('s'));
12799        app.handle_action(Action::FilterInput('t'));
12800        assert_eq!(app.iam_state.roles.filter, "test");
12801    }
12802
12803    #[test]
12804    fn test_iam_roles_page_down_updates_scroll_offset() {
12805        let mut app = test_app();
12806        app.current_service = Service::IamRoles;
12807        app.mode = Mode::Normal;
12808        app.iam_state.roles.items = (0..50)
12809            .map(|i| IamRole {
12810                role_name: format!("role-{}", i),
12811                path: "/".to_string(),
12812                trusted_entities: "AWS Service".to_string(),
12813                last_activity: "N/A".to_string(),
12814                arn: format!("arn:aws:iam::123456789012:role/role-{}", i),
12815                creation_time: "2024-01-01".to_string(),
12816                description: String::new(),
12817                max_session_duration: Some(3600),
12818            })
12819            .collect();
12820
12821        app.iam_state.roles.selected = 0;
12822        app.iam_state.roles.scroll_offset = 0;
12823
12824        // Page down should update both selected and scroll_offset
12825        app.handle_action(Action::PageDown);
12826        assert_eq!(app.iam_state.roles.selected, 10);
12827        // scroll_offset should be updated to keep selection visible
12828        assert!(app.iam_state.roles.scroll_offset <= app.iam_state.roles.selected);
12829
12830        // Page down again
12831        app.handle_action(Action::PageDown);
12832        assert_eq!(app.iam_state.roles.selected, 20);
12833        assert!(app.iam_state.roles.scroll_offset <= app.iam_state.roles.selected);
12834    }
12835
12836    #[test]
12837    fn test_application_selection_and_deployments_tab() {
12838        use crate::lambda::Application as LambdaApplication;
12839        use LambdaApplicationDetailTab;
12840
12841        let mut app = test_app();
12842        app.current_service = Service::LambdaApplications;
12843        app.service_selected = true;
12844        app.mode = Mode::Normal;
12845
12846        app.lambda_application_state.table.items = vec![LambdaApplication {
12847            name: "test-app".to_string(),
12848            arn: "arn:aws:serverlessrepo:::applications/test-app".to_string(),
12849            description: "Test application".to_string(),
12850            status: "CREATE_COMPLETE".to_string(),
12851            last_modified: "2024-01-01".to_string(),
12852        }];
12853
12854        // Select application
12855        app.handle_action(Action::Select);
12856        assert_eq!(
12857            app.lambda_application_state.current_application,
12858            Some("test-app".to_string())
12859        );
12860        assert_eq!(
12861            app.lambda_application_state.detail_tab,
12862            LambdaApplicationDetailTab::Overview
12863        );
12864
12865        // Switch to Deployments tab
12866        app.handle_action(Action::NextDetailTab);
12867        assert_eq!(
12868            app.lambda_application_state.detail_tab,
12869            LambdaApplicationDetailTab::Deployments
12870        );
12871
12872        // Go back
12873        app.handle_action(Action::GoBack);
12874        assert_eq!(app.lambda_application_state.current_application, None);
12875    }
12876
12877    #[test]
12878    fn test_application_resources_filter_and_pagination() {
12879        use crate::lambda::Application as LambdaApplication;
12880        use LambdaApplicationDetailTab;
12881
12882        let mut app = test_app();
12883        app.current_service = Service::LambdaApplications;
12884        app.service_selected = true;
12885        app.mode = Mode::Normal;
12886
12887        app.lambda_application_state.table.items = vec![LambdaApplication {
12888            name: "test-app".to_string(),
12889            arn: "arn:aws:serverlessrepo:::applications/test-app".to_string(),
12890            description: "Test application".to_string(),
12891            status: "CREATE_COMPLETE".to_string(),
12892            last_modified: "2024-01-01".to_string(),
12893        }];
12894
12895        // Select application
12896        app.handle_action(Action::Select);
12897        assert_eq!(
12898            app.lambda_application_state.detail_tab,
12899            LambdaApplicationDetailTab::Overview
12900        );
12901
12902        // Verify resources were loaded
12903        assert!(!app.lambda_application_state.resources.items.is_empty());
12904
12905        // Test filter focus cycling
12906        app.mode = Mode::FilterInput;
12907        assert_eq!(
12908            app.lambda_application_state.resource_input_focus,
12909            InputFocus::Filter
12910        );
12911
12912        app.handle_action(Action::NextFilterFocus);
12913        assert_eq!(
12914            app.lambda_application_state.resource_input_focus,
12915            InputFocus::Pagination
12916        );
12917
12918        app.handle_action(Action::PrevFilterFocus);
12919        assert_eq!(
12920            app.lambda_application_state.resource_input_focus,
12921            InputFocus::Filter
12922        );
12923    }
12924
12925    #[test]
12926    fn test_application_deployments_filter_and_pagination() {
12927        use crate::lambda::Application as LambdaApplication;
12928        use LambdaApplicationDetailTab;
12929
12930        let mut app = test_app();
12931        app.current_service = Service::LambdaApplications;
12932        app.service_selected = true;
12933        app.mode = Mode::Normal;
12934
12935        app.lambda_application_state.table.items = vec![LambdaApplication {
12936            name: "test-app".to_string(),
12937            arn: "arn:aws:serverlessrepo:::applications/test-app".to_string(),
12938            description: "Test application".to_string(),
12939            status: "CREATE_COMPLETE".to_string(),
12940            last_modified: "2024-01-01".to_string(),
12941        }];
12942
12943        // Select application and switch to Deployments tab
12944        app.handle_action(Action::Select);
12945        app.handle_action(Action::NextDetailTab);
12946        assert_eq!(
12947            app.lambda_application_state.detail_tab,
12948            LambdaApplicationDetailTab::Deployments
12949        );
12950
12951        // Verify deployments were loaded
12952        assert!(!app.lambda_application_state.deployments.items.is_empty());
12953
12954        // Test filter focus cycling
12955        app.mode = Mode::FilterInput;
12956        assert_eq!(
12957            app.lambda_application_state.deployment_input_focus,
12958            InputFocus::Filter
12959        );
12960
12961        app.handle_action(Action::NextFilterFocus);
12962        assert_eq!(
12963            app.lambda_application_state.deployment_input_focus,
12964            InputFocus::Pagination
12965        );
12966
12967        app.handle_action(Action::PrevFilterFocus);
12968        assert_eq!(
12969            app.lambda_application_state.deployment_input_focus,
12970            InputFocus::Filter
12971        );
12972    }
12973
12974    #[test]
12975    fn test_application_resource_expansion() {
12976        use crate::lambda::Application as LambdaApplication;
12977        use LambdaApplicationDetailTab;
12978
12979        let mut app = test_app();
12980        app.current_service = Service::LambdaApplications;
12981        app.service_selected = true;
12982        app.mode = Mode::Normal;
12983
12984        app.lambda_application_state.table.items = vec![LambdaApplication {
12985            name: "test-app".to_string(),
12986            arn: "arn:aws:serverlessrepo:::applications/test-app".to_string(),
12987            description: "Test application".to_string(),
12988            status: "CREATE_COMPLETE".to_string(),
12989            last_modified: "2024-01-01".to_string(),
12990        }];
12991
12992        // Select application (Overview tab by default)
12993        app.handle_action(Action::Select);
12994        assert_eq!(
12995            app.lambda_application_state.detail_tab,
12996            LambdaApplicationDetailTab::Overview
12997        );
12998
12999        // Expand resource
13000        app.handle_action(Action::NextPane);
13001        assert_eq!(
13002            app.lambda_application_state.resources.expanded_item,
13003            Some(0)
13004        );
13005
13006        // Collapse resource
13007        app.handle_action(Action::PrevPane);
13008        assert_eq!(app.lambda_application_state.resources.expanded_item, None);
13009    }
13010
13011    #[test]
13012    fn test_application_deployment_expansion() {
13013        use crate::lambda::Application as LambdaApplication;
13014        use LambdaApplicationDetailTab;
13015
13016        let mut app = test_app();
13017        app.current_service = Service::LambdaApplications;
13018        app.service_selected = true;
13019        app.mode = Mode::Normal;
13020
13021        app.lambda_application_state.table.items = vec![LambdaApplication {
13022            name: "test-app".to_string(),
13023            arn: "arn:aws:serverlessrepo:::applications/test-app".to_string(),
13024            description: "Test application".to_string(),
13025            status: "CREATE_COMPLETE".to_string(),
13026            last_modified: "2024-01-01".to_string(),
13027        }];
13028
13029        // Select application and switch to Deployments tab
13030        app.handle_action(Action::Select);
13031        app.handle_action(Action::NextDetailTab);
13032        assert_eq!(
13033            app.lambda_application_state.detail_tab,
13034            LambdaApplicationDetailTab::Deployments
13035        );
13036
13037        // Expand deployment
13038        app.handle_action(Action::NextPane);
13039        assert_eq!(
13040            app.lambda_application_state.deployments.expanded_item,
13041            Some(0)
13042        );
13043
13044        // Collapse deployment
13045        app.handle_action(Action::PrevPane);
13046        assert_eq!(app.lambda_application_state.deployments.expanded_item, None);
13047    }
13048
13049    #[test]
13050    fn test_s3_nested_prefix_expansion() {
13051        use crate::s3::Bucket;
13052        use S3Object;
13053
13054        let mut app = test_app();
13055        app.current_service = Service::S3Buckets;
13056        app.service_selected = true;
13057        app.mode = Mode::Normal;
13058
13059        // Setup bucket with nested prefixes (2 levels)
13060        app.s3_state.buckets.items = vec![Bucket {
13061            name: "test-bucket".to_string(),
13062            region: "us-east-1".to_string(),
13063            creation_date: "2024-01-01".to_string(),
13064        }];
13065
13066        // Level 1: bucket preview
13067        app.s3_state.bucket_preview.insert(
13068            "test-bucket".to_string(),
13069            vec![S3Object {
13070                key: "level1/".to_string(),
13071                size: 0,
13072                last_modified: "".to_string(),
13073                is_prefix: true,
13074                storage_class: "".to_string(),
13075            }],
13076        );
13077
13078        // Level 2: nested prefix
13079        app.s3_state.prefix_preview.insert(
13080            "level1/".to_string(),
13081            vec![S3Object {
13082                key: "level1/level2/".to_string(),
13083                size: 0,
13084                last_modified: "".to_string(),
13085                is_prefix: true,
13086                storage_class: "".to_string(),
13087            }],
13088        );
13089
13090        // Expand bucket (row 0)
13091        app.s3_state.selected_row = 0;
13092        app.handle_action(Action::NextPane);
13093        assert!(app.s3_state.expanded_prefixes.contains("test-bucket"));
13094
13095        // Expand level1/ (row 1)
13096        app.s3_state.selected_row = 1;
13097        app.handle_action(Action::NextPane);
13098        assert!(app.s3_state.expanded_prefixes.contains("level1/"));
13099
13100        // Expand level2/ (row 2) - verifies nested expansion works
13101        app.s3_state.selected_row = 2;
13102        app.handle_action(Action::NextPane);
13103        assert!(app.s3_state.expanded_prefixes.contains("level1/level2/"));
13104
13105        // Verify all are still expanded
13106        assert!(app.s3_state.expanded_prefixes.contains("test-bucket"));
13107        assert!(app.s3_state.expanded_prefixes.contains("level1/"));
13108    }
13109
13110    #[test]
13111    fn test_s3_nested_prefix_collapse() {
13112        use crate::s3::Bucket;
13113        use S3Object;
13114
13115        let mut app = test_app();
13116        app.current_service = Service::S3Buckets;
13117        app.service_selected = true;
13118        app.mode = Mode::Normal;
13119
13120        app.s3_state.buckets.items = vec![Bucket {
13121            name: "test-bucket".to_string(),
13122            region: "us-east-1".to_string(),
13123            creation_date: "2024-01-01".to_string(),
13124        }];
13125
13126        app.s3_state.bucket_preview.insert(
13127            "test-bucket".to_string(),
13128            vec![S3Object {
13129                key: "level1/".to_string(),
13130                size: 0,
13131                last_modified: "".to_string(),
13132                is_prefix: true,
13133                storage_class: "".to_string(),
13134            }],
13135        );
13136
13137        app.s3_state.prefix_preview.insert(
13138            "level1/".to_string(),
13139            vec![S3Object {
13140                key: "level1/level2/".to_string(),
13141                size: 0,
13142                last_modified: "".to_string(),
13143                is_prefix: true,
13144                storage_class: "".to_string(),
13145            }],
13146        );
13147
13148        // Pre-expand all levels
13149        app.s3_state
13150            .expanded_prefixes
13151            .insert("test-bucket".to_string());
13152        app.s3_state.expanded_prefixes.insert("level1/".to_string());
13153        app.s3_state
13154            .expanded_prefixes
13155            .insert("level1/level2/".to_string());
13156
13157        // Collapse level2/ (row 2)
13158        app.s3_state.selected_row = 2;
13159        app.handle_action(Action::PrevPane);
13160        assert!(!app.s3_state.expanded_prefixes.contains("level1/level2/"));
13161        assert!(app.s3_state.expanded_prefixes.contains("level1/")); // Parent still expanded
13162
13163        // Collapse level1/ (row 1)
13164        app.s3_state.selected_row = 1;
13165        app.handle_action(Action::PrevPane);
13166        assert!(!app.s3_state.expanded_prefixes.contains("level1/"));
13167        assert!(app.s3_state.expanded_prefixes.contains("test-bucket")); // Bucket still expanded
13168
13169        // Collapse bucket (row 0)
13170        app.s3_state.selected_row = 0;
13171        app.handle_action(Action::PrevPane);
13172        assert!(!app.s3_state.expanded_prefixes.contains("test-bucket"));
13173    }
13174}
13175
13176#[cfg(test)]
13177mod sqs_tests {
13178    use super::*;
13179    use test_helpers::*;
13180
13181    #[test]
13182    fn test_sqs_filter_input() {
13183        let mut app = test_app();
13184        app.current_service = Service::SqsQueues;
13185        app.service_selected = true;
13186        app.mode = Mode::FilterInput;
13187
13188        app.handle_action(Action::FilterInput('t'));
13189        app.handle_action(Action::FilterInput('e'));
13190        app.handle_action(Action::FilterInput('s'));
13191        app.handle_action(Action::FilterInput('t'));
13192        assert_eq!(app.sqs_state.queues.filter, "test");
13193
13194        app.handle_action(Action::FilterBackspace);
13195        assert_eq!(app.sqs_state.queues.filter, "tes");
13196    }
13197
13198    #[test]
13199    fn test_sqs_start_filter() {
13200        let mut app = test_app();
13201        app.current_service = Service::SqsQueues;
13202        app.service_selected = true;
13203        app.mode = Mode::Normal;
13204
13205        app.handle_action(Action::StartFilter);
13206        assert_eq!(app.mode, Mode::FilterInput);
13207        assert_eq!(app.sqs_state.input_focus, InputFocus::Filter);
13208    }
13209
13210    #[test]
13211    fn test_sqs_filter_focus_cycling() {
13212        let mut app = test_app();
13213        app.current_service = Service::SqsQueues;
13214        app.service_selected = true;
13215        app.mode = Mode::FilterInput;
13216        app.sqs_state.input_focus = InputFocus::Filter;
13217
13218        app.handle_action(Action::NextFilterFocus);
13219        assert_eq!(app.sqs_state.input_focus, InputFocus::Pagination);
13220
13221        app.handle_action(Action::NextFilterFocus);
13222        assert_eq!(app.sqs_state.input_focus, InputFocus::Filter);
13223
13224        app.handle_action(Action::PrevFilterFocus);
13225        assert_eq!(app.sqs_state.input_focus, InputFocus::Pagination);
13226    }
13227
13228    #[test]
13229    fn test_sqs_navigation() {
13230        let mut app = test_app();
13231        app.current_service = Service::SqsQueues;
13232        app.service_selected = true;
13233        app.mode = Mode::Normal;
13234        app.sqs_state.queues.items = (0..10)
13235            .map(|i| SqsQueue {
13236                name: format!("queue{}", i),
13237                url: String::new(),
13238                queue_type: "Standard".to_string(),
13239                created_timestamp: String::new(),
13240                messages_available: "0".to_string(),
13241                messages_in_flight: "0".to_string(),
13242                encryption: "Disabled".to_string(),
13243                content_based_deduplication: "Disabled".to_string(),
13244                last_modified_timestamp: String::new(),
13245                visibility_timeout: String::new(),
13246                message_retention_period: String::new(),
13247                maximum_message_size: String::new(),
13248                delivery_delay: String::new(),
13249                receive_message_wait_time: String::new(),
13250                high_throughput_fifo: "N/A".to_string(),
13251                deduplication_scope: "N/A".to_string(),
13252                fifo_throughput_limit: "N/A".to_string(),
13253                dead_letter_queue: "-".to_string(),
13254                messages_delayed: "0".to_string(),
13255                redrive_allow_policy: "-".to_string(),
13256                redrive_policy: "".to_string(),
13257                redrive_task_id: "-".to_string(),
13258                redrive_task_start_time: "-".to_string(),
13259                redrive_task_status: "-".to_string(),
13260                redrive_task_percent: "-".to_string(),
13261                redrive_task_destination: "-".to_string(),
13262            })
13263            .collect();
13264
13265        app.handle_action(Action::NextItem);
13266        assert_eq!(app.sqs_state.queues.selected, 1);
13267
13268        app.handle_action(Action::PrevItem);
13269        assert_eq!(app.sqs_state.queues.selected, 0);
13270    }
13271
13272    #[test]
13273    fn test_sqs_page_navigation() {
13274        let mut app = test_app();
13275        app.current_service = Service::SqsQueues;
13276        app.service_selected = true;
13277        app.mode = Mode::Normal;
13278        app.sqs_state.queues.items = (0..100)
13279            .map(|i| SqsQueue {
13280                name: format!("queue{}", i),
13281                url: String::new(),
13282                queue_type: "Standard".to_string(),
13283                created_timestamp: String::new(),
13284                messages_available: "0".to_string(),
13285                messages_in_flight: "0".to_string(),
13286                encryption: "Disabled".to_string(),
13287                content_based_deduplication: "Disabled".to_string(),
13288                last_modified_timestamp: String::new(),
13289                visibility_timeout: String::new(),
13290                message_retention_period: String::new(),
13291                maximum_message_size: String::new(),
13292                delivery_delay: String::new(),
13293                receive_message_wait_time: String::new(),
13294                high_throughput_fifo: "N/A".to_string(),
13295                deduplication_scope: "N/A".to_string(),
13296                fifo_throughput_limit: "N/A".to_string(),
13297                dead_letter_queue: "-".to_string(),
13298                messages_delayed: "0".to_string(),
13299                redrive_allow_policy: "-".to_string(),
13300                redrive_policy: "".to_string(),
13301                redrive_task_id: "-".to_string(),
13302                redrive_task_start_time: "-".to_string(),
13303                redrive_task_status: "-".to_string(),
13304                redrive_task_percent: "-".to_string(),
13305                redrive_task_destination: "-".to_string(),
13306            })
13307            .collect();
13308
13309        app.handle_action(Action::PageDown);
13310        assert_eq!(app.sqs_state.queues.selected, 10);
13311
13312        app.handle_action(Action::PageUp);
13313        assert_eq!(app.sqs_state.queues.selected, 0);
13314    }
13315
13316    #[test]
13317    fn test_sqs_queue_expansion() {
13318        let mut app = test_app();
13319        app.current_service = Service::SqsQueues;
13320        app.service_selected = true;
13321        app.sqs_state.queues.items = vec![SqsQueue {
13322            name: "my-queue".to_string(),
13323            url: "https://sqs.us-east-1.amazonaws.com/123456789012/my-queue".to_string(),
13324            queue_type: "Standard".to_string(),
13325            created_timestamp: "2023-01-01".to_string(),
13326            messages_available: "5".to_string(),
13327            messages_in_flight: "2".to_string(),
13328            encryption: "Enabled".to_string(),
13329            content_based_deduplication: "Disabled".to_string(),
13330            last_modified_timestamp: "2023-01-02".to_string(),
13331            visibility_timeout: "30".to_string(),
13332            message_retention_period: "345600".to_string(),
13333            maximum_message_size: "262144".to_string(),
13334            delivery_delay: "0".to_string(),
13335            receive_message_wait_time: "0".to_string(),
13336            high_throughput_fifo: "N/A".to_string(),
13337            deduplication_scope: "N/A".to_string(),
13338            fifo_throughput_limit: "N/A".to_string(),
13339            dead_letter_queue: "-".to_string(),
13340            messages_delayed: "0".to_string(),
13341            redrive_allow_policy: "-".to_string(),
13342            redrive_policy: "".to_string(),
13343            redrive_task_id: "-".to_string(),
13344            redrive_task_start_time: "-".to_string(),
13345            redrive_task_status: "-".to_string(),
13346            redrive_task_percent: "-".to_string(),
13347            redrive_task_destination: "-".to_string(),
13348        }];
13349        app.sqs_state.queues.selected = 0;
13350
13351        assert_eq!(app.sqs_state.queues.expanded_item, None);
13352
13353        // Right arrow expands
13354        app.handle_action(Action::NextPane);
13355        assert_eq!(app.sqs_state.queues.expanded_item, Some(0));
13356
13357        // Right arrow again keeps it expanded
13358        app.handle_action(Action::NextPane);
13359        assert_eq!(app.sqs_state.queues.expanded_item, Some(0));
13360
13361        // Left arrow collapses
13362        app.handle_action(Action::PrevPane);
13363        assert_eq!(app.sqs_state.queues.expanded_item, None);
13364
13365        // Left arrow again keeps it collapsed
13366        app.handle_action(Action::PrevPane);
13367        assert_eq!(app.sqs_state.queues.expanded_item, None);
13368    }
13369
13370    #[test]
13371    fn test_sqs_column_toggle() {
13372        use crate::sqs::queue::Column as SqsColumn;
13373        let mut app = test_app();
13374        app.current_service = Service::SqsQueues;
13375        app.service_selected = true;
13376        app.mode = Mode::ColumnSelector;
13377
13378        // Start with all columns visible
13379        app.sqs_visible_column_ids = SqsColumn::ids();
13380        let initial_count = app.sqs_visible_column_ids.len();
13381
13382        // Index 0 is the Columns header; the first column is row 1.
13383        app.column_selector_index = 1;
13384        app.handle_action(Action::ToggleColumn);
13385
13386        // First column should be removed
13387        assert_eq!(app.sqs_visible_column_ids.len(), initial_count - 1);
13388        assert!(!app.sqs_visible_column_ids.contains(&SqsColumn::Name.id()));
13389
13390        // Toggle it back
13391        app.handle_action(Action::ToggleColumn);
13392        assert_eq!(app.sqs_visible_column_ids.len(), initial_count);
13393        assert!(app.sqs_visible_column_ids.contains(&SqsColumn::Name.id()));
13394    }
13395
13396    #[test]
13397    fn test_sqs_column_selector_navigation() {
13398        let mut app = test_app();
13399        app.current_service = Service::SqsQueues;
13400        app.service_selected = true;
13401        app.mode = Mode::ColumnSelector;
13402        app.column_selector_index = 0;
13403
13404        // Should be able to navigate through all columns
13405        let max_index = app.sqs_column_ids.len() - 1;
13406
13407        // Navigate to last column
13408        for _ in 0..max_index {
13409            app.handle_action(Action::NextItem);
13410        }
13411        assert_eq!(app.column_selector_index, max_index);
13412
13413        // Navigate back to first
13414        for _ in 0..max_index {
13415            app.handle_action(Action::PrevItem);
13416        }
13417        assert_eq!(app.column_selector_index, 0);
13418    }
13419
13420    #[test]
13421    fn test_sqs_queue_selection() {
13422        let mut app = test_app();
13423        app.current_service = Service::SqsQueues;
13424        app.service_selected = true;
13425        app.mode = Mode::Normal;
13426        app.sqs_state.queues.items = vec![SqsQueue {
13427            name: "my-queue".to_string(),
13428            url: "https://sqs.us-east-1.amazonaws.com/123456789012/my-queue".to_string(),
13429            queue_type: "Standard".to_string(),
13430            created_timestamp: "2023-01-01".to_string(),
13431            messages_available: "5".to_string(),
13432            messages_in_flight: "2".to_string(),
13433            encryption: "Enabled".to_string(),
13434            content_based_deduplication: "Disabled".to_string(),
13435            last_modified_timestamp: "2023-01-02".to_string(),
13436            visibility_timeout: "30".to_string(),
13437            message_retention_period: "345600".to_string(),
13438            maximum_message_size: "262144".to_string(),
13439            delivery_delay: "0".to_string(),
13440            receive_message_wait_time: "0".to_string(),
13441            high_throughput_fifo: "N/A".to_string(),
13442            deduplication_scope: "N/A".to_string(),
13443            fifo_throughput_limit: "N/A".to_string(),
13444            dead_letter_queue: "-".to_string(),
13445            messages_delayed: "0".to_string(),
13446            redrive_allow_policy: "-".to_string(),
13447            redrive_policy: "".to_string(),
13448            redrive_task_id: "-".to_string(),
13449            redrive_task_start_time: "-".to_string(),
13450            redrive_task_status: "-".to_string(),
13451            redrive_task_percent: "-".to_string(),
13452            redrive_task_destination: "-".to_string(),
13453        }];
13454        app.sqs_state.queues.selected = 0;
13455
13456        assert_eq!(app.sqs_state.current_queue, None);
13457
13458        // Select queue
13459        app.handle_action(Action::Select);
13460        assert_eq!(
13461            app.sqs_state.current_queue,
13462            Some("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue".to_string())
13463        );
13464
13465        // Go back
13466        app.handle_action(Action::GoBack);
13467        assert_eq!(app.sqs_state.current_queue, None);
13468    }
13469
13470    #[test]
13471    fn test_sqs_lambda_triggers_expand_collapse() {
13472        let mut app = test_app();
13473        app.current_service = Service::SqsQueues;
13474        app.service_selected = true;
13475        app.sqs_state.current_queue =
13476            Some("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue".to_string());
13477        app.sqs_state.detail_tab = SqsQueueDetailTab::LambdaTriggers;
13478        app.sqs_state.triggers.items = vec![LambdaTrigger {
13479            uuid: "test-uuid".to_string(),
13480            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
13481            status: "Enabled".to_string(),
13482            last_modified: "2024-01-01T00:00:00Z".to_string(),
13483        }];
13484        app.sqs_state.triggers.selected = 0;
13485
13486        assert_eq!(app.sqs_state.triggers.expanded_item, None);
13487
13488        // Right arrow expands
13489        app.handle_action(Action::NextPane);
13490        assert_eq!(app.sqs_state.triggers.expanded_item, Some(0));
13491
13492        // Left arrow collapses
13493        app.handle_action(Action::PrevPane);
13494        assert_eq!(app.sqs_state.triggers.expanded_item, None);
13495    }
13496
13497    #[test]
13498    fn test_sqs_lambda_triggers_expand_toggle() {
13499        let mut app = test_app();
13500        app.current_service = Service::SqsQueues;
13501        app.service_selected = true;
13502        app.sqs_state.current_queue =
13503            Some("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue".to_string());
13504        app.sqs_state.detail_tab = SqsQueueDetailTab::LambdaTriggers;
13505        app.sqs_state.triggers.items = vec![LambdaTrigger {
13506            uuid: "test-uuid".to_string(),
13507            arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
13508            status: "Enabled".to_string(),
13509            last_modified: "2024-01-01T00:00:00Z".to_string(),
13510        }];
13511        app.sqs_state.triggers.selected = 0;
13512
13513        // Expand
13514        app.handle_action(Action::NextPane);
13515        assert_eq!(app.sqs_state.triggers.expanded_item, Some(0));
13516
13517        // Toggle collapses
13518        app.handle_action(Action::NextPane);
13519        assert_eq!(app.sqs_state.triggers.expanded_item, None);
13520
13521        // Toggle expands again
13522        app.handle_action(Action::NextPane);
13523        assert_eq!(app.sqs_state.triggers.expanded_item, Some(0));
13524    }
13525
13526    #[test]
13527    fn test_sqs_lambda_triggers_sorted_by_last_modified_asc() {
13528        use crate::ui::sqs::filtered_lambda_triggers;
13529
13530        let mut app = test_app();
13531        app.current_service = Service::SqsQueues;
13532        app.service_selected = true;
13533        app.sqs_state.current_queue =
13534            Some("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue".to_string());
13535        app.sqs_state.detail_tab = SqsQueueDetailTab::LambdaTriggers;
13536        app.sqs_state.triggers.items = vec![
13537            LambdaTrigger {
13538                uuid: "uuid-3".to_string(),
13539                arn: "arn:aws:lambda:us-east-1:123456789012:function:test-3".to_string(),
13540                status: "Enabled".to_string(),
13541                last_modified: "2024-03-01T00:00:00Z".to_string(),
13542            },
13543            LambdaTrigger {
13544                uuid: "uuid-1".to_string(),
13545                arn: "arn:aws:lambda:us-east-1:123456789012:function:test-1".to_string(),
13546                status: "Enabled".to_string(),
13547                last_modified: "2024-01-01T00:00:00Z".to_string(),
13548            },
13549            LambdaTrigger {
13550                uuid: "uuid-2".to_string(),
13551                arn: "arn:aws:lambda:us-east-1:123456789012:function:test-2".to_string(),
13552                status: "Enabled".to_string(),
13553                last_modified: "2024-02-01T00:00:00Z".to_string(),
13554            },
13555        ];
13556
13557        let sorted = filtered_lambda_triggers(&app);
13558
13559        // Should be sorted by last_modified ASC
13560        assert_eq!(sorted.len(), 3);
13561        assert_eq!(sorted[0].uuid, "uuid-1");
13562        assert_eq!(sorted[0].last_modified, "2024-01-01T00:00:00Z");
13563        assert_eq!(sorted[1].uuid, "uuid-2");
13564        assert_eq!(sorted[1].last_modified, "2024-02-01T00:00:00Z");
13565        assert_eq!(sorted[2].uuid, "uuid-3");
13566        assert_eq!(sorted[2].last_modified, "2024-03-01T00:00:00Z");
13567    }
13568
13569    #[test]
13570    fn test_sqs_lambda_triggers_filter_input() {
13571        let mut app = test_app();
13572        app.current_service = Service::SqsQueues;
13573        app.service_selected = true;
13574        app.mode = Mode::FilterInput;
13575        app.sqs_state.current_queue =
13576            Some("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue".to_string());
13577        app.sqs_state.detail_tab = SqsQueueDetailTab::LambdaTriggers;
13578        app.sqs_state.input_focus = InputFocus::Filter;
13579
13580        assert_eq!(app.sqs_state.triggers.filter, "");
13581
13582        // Type characters
13583        app.handle_action(Action::FilterInput('t'));
13584        assert_eq!(app.sqs_state.triggers.filter, "t");
13585
13586        app.handle_action(Action::FilterInput('e'));
13587        assert_eq!(app.sqs_state.triggers.filter, "te");
13588
13589        app.handle_action(Action::FilterInput('s'));
13590        assert_eq!(app.sqs_state.triggers.filter, "tes");
13591
13592        app.handle_action(Action::FilterInput('t'));
13593        assert_eq!(app.sqs_state.triggers.filter, "test");
13594
13595        // Backspace
13596        app.handle_action(Action::FilterBackspace);
13597        assert_eq!(app.sqs_state.triggers.filter, "tes");
13598    }
13599
13600    #[test]
13601    fn test_sqs_lambda_triggers_filter_applied() {
13602        use crate::ui::sqs::filtered_lambda_triggers;
13603
13604        let mut app = test_app();
13605        app.current_service = Service::SqsQueues;
13606        app.service_selected = true;
13607        app.sqs_state.current_queue =
13608            Some("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue".to_string());
13609        app.sqs_state.detail_tab = SqsQueueDetailTab::LambdaTriggers;
13610        app.sqs_state.triggers.items = vec![
13611            LambdaTrigger {
13612                uuid: "uuid-1".to_string(),
13613                arn: "arn:aws:lambda:us-east-1:123456789012:function:test-alpha".to_string(),
13614                status: "Enabled".to_string(),
13615                last_modified: "2024-01-01T00:00:00Z".to_string(),
13616            },
13617            LambdaTrigger {
13618                uuid: "uuid-2".to_string(),
13619                arn: "arn:aws:lambda:us-east-1:123456789012:function:test-beta".to_string(),
13620                status: "Enabled".to_string(),
13621                last_modified: "2024-02-01T00:00:00Z".to_string(),
13622            },
13623            LambdaTrigger {
13624                uuid: "uuid-3".to_string(),
13625                arn: "arn:aws:lambda:us-east-1:123456789012:function:prod-gamma".to_string(),
13626                status: "Enabled".to_string(),
13627                last_modified: "2024-03-01T00:00:00Z".to_string(),
13628            },
13629        ];
13630
13631        // No filter - all items
13632        let filtered = filtered_lambda_triggers(&app);
13633        assert_eq!(filtered.len(), 3);
13634
13635        // Filter by "alpha"
13636        app.sqs_state.triggers.filter = "alpha".to_string();
13637        let filtered = filtered_lambda_triggers(&app);
13638        assert_eq!(filtered.len(), 1);
13639        assert_eq!(
13640            filtered[0].arn,
13641            "arn:aws:lambda:us-east-1:123456789012:function:test-alpha"
13642        );
13643
13644        // Filter by "test" - matches 2
13645        app.sqs_state.triggers.filter = "test".to_string();
13646        let filtered = filtered_lambda_triggers(&app);
13647        assert_eq!(filtered.len(), 2);
13648        assert_eq!(
13649            filtered[0].arn,
13650            "arn:aws:lambda:us-east-1:123456789012:function:test-alpha"
13651        );
13652        assert_eq!(
13653            filtered[1].arn,
13654            "arn:aws:lambda:us-east-1:123456789012:function:test-beta"
13655        );
13656
13657        // Filter by uuid
13658        app.sqs_state.triggers.filter = "uuid-3".to_string();
13659        let filtered = filtered_lambda_triggers(&app);
13660        assert_eq!(filtered.len(), 1);
13661        assert_eq!(filtered[0].uuid, "uuid-3");
13662    }
13663
13664    #[test]
13665    fn test_sqs_triggers_navigation() {
13666        let mut app = test_app();
13667        app.service_selected = true;
13668        app.mode = Mode::Normal;
13669        app.current_service = Service::SqsQueues;
13670        app.sqs_state.current_queue = Some("test-queue".to_string());
13671        app.sqs_state.detail_tab = SqsQueueDetailTab::LambdaTriggers;
13672        app.sqs_state.triggers.items = vec![
13673            LambdaTrigger {
13674                uuid: "1".to_string(),
13675                arn: "arn1".to_string(),
13676                status: "Enabled".to_string(),
13677                last_modified: "2024-01-01".to_string(),
13678            },
13679            LambdaTrigger {
13680                uuid: "2".to_string(),
13681                arn: "arn2".to_string(),
13682                status: "Enabled".to_string(),
13683                last_modified: "2024-01-02".to_string(),
13684            },
13685        ];
13686
13687        assert_eq!(app.sqs_state.triggers.selected, 0);
13688        app.next_item();
13689        assert_eq!(app.sqs_state.triggers.selected, 1);
13690        app.prev_item();
13691        assert_eq!(app.sqs_state.triggers.selected, 0);
13692    }
13693
13694    #[test]
13695    fn test_sqs_pipes_navigation() {
13696        let mut app = test_app();
13697        app.service_selected = true;
13698        app.mode = Mode::Normal;
13699        app.current_service = Service::SqsQueues;
13700        app.sqs_state.current_queue = Some("test-queue".to_string());
13701        app.sqs_state.detail_tab = SqsQueueDetailTab::EventBridgePipes;
13702        app.sqs_state.pipes.items = vec![
13703            EventBridgePipe {
13704                name: "pipe1".to_string(),
13705                status: "RUNNING".to_string(),
13706                target: "target1".to_string(),
13707                last_modified: "2024-01-01".to_string(),
13708            },
13709            EventBridgePipe {
13710                name: "pipe2".to_string(),
13711                status: "RUNNING".to_string(),
13712                target: "target2".to_string(),
13713                last_modified: "2024-01-02".to_string(),
13714            },
13715        ];
13716
13717        assert_eq!(app.sqs_state.pipes.selected, 0);
13718        app.next_item();
13719        assert_eq!(app.sqs_state.pipes.selected, 1);
13720        app.prev_item();
13721        assert_eq!(app.sqs_state.pipes.selected, 0);
13722    }
13723
13724    #[test]
13725    fn test_sqs_tags_navigation() {
13726        let mut app = test_app();
13727        app.service_selected = true;
13728        app.mode = Mode::Normal;
13729        app.current_service = Service::SqsQueues;
13730        app.sqs_state.current_queue = Some("test-queue".to_string());
13731        app.sqs_state.detail_tab = SqsQueueDetailTab::Tagging;
13732        app.sqs_state.tags.items = vec![
13733            SqsQueueTag {
13734                key: "Env".to_string(),
13735                value: "prod".to_string(),
13736            },
13737            SqsQueueTag {
13738                key: "Team".to_string(),
13739                value: "backend".to_string(),
13740            },
13741        ];
13742
13743        assert_eq!(app.sqs_state.tags.selected, 0);
13744        app.next_item();
13745        assert_eq!(app.sqs_state.tags.selected, 1);
13746        app.prev_item();
13747        assert_eq!(app.sqs_state.tags.selected, 0);
13748    }
13749
13750    #[test]
13751    fn test_sqs_queues_navigation() {
13752        let mut app = test_app();
13753        app.service_selected = true;
13754        app.mode = Mode::Normal;
13755        app.current_service = Service::SqsQueues;
13756        app.sqs_state.queues.items = vec![
13757            SqsQueue {
13758                name: "queue1".to_string(),
13759                url: "url1".to_string(),
13760                queue_type: "Standard".to_string(),
13761                created_timestamp: "".to_string(),
13762                messages_available: "0".to_string(),
13763                messages_in_flight: "0".to_string(),
13764                encryption: "Disabled".to_string(),
13765                content_based_deduplication: "Disabled".to_string(),
13766                last_modified_timestamp: "".to_string(),
13767                visibility_timeout: "".to_string(),
13768                message_retention_period: "".to_string(),
13769                maximum_message_size: "".to_string(),
13770                delivery_delay: "".to_string(),
13771                receive_message_wait_time: "".to_string(),
13772                high_throughput_fifo: "-".to_string(),
13773                deduplication_scope: "-".to_string(),
13774                fifo_throughput_limit: "-".to_string(),
13775                dead_letter_queue: "-".to_string(),
13776                messages_delayed: "0".to_string(),
13777                redrive_allow_policy: "-".to_string(),
13778                redrive_policy: "".to_string(),
13779                redrive_task_id: "-".to_string(),
13780                redrive_task_start_time: "-".to_string(),
13781                redrive_task_status: "-".to_string(),
13782                redrive_task_percent: "-".to_string(),
13783                redrive_task_destination: "-".to_string(),
13784            },
13785            SqsQueue {
13786                name: "queue2".to_string(),
13787                url: "url2".to_string(),
13788                queue_type: "Standard".to_string(),
13789                created_timestamp: "".to_string(),
13790                messages_available: "0".to_string(),
13791                messages_in_flight: "0".to_string(),
13792                encryption: "Disabled".to_string(),
13793                content_based_deduplication: "Disabled".to_string(),
13794                last_modified_timestamp: "".to_string(),
13795                visibility_timeout: "".to_string(),
13796                message_retention_period: "".to_string(),
13797                maximum_message_size: "".to_string(),
13798                delivery_delay: "".to_string(),
13799                receive_message_wait_time: "".to_string(),
13800                high_throughput_fifo: "-".to_string(),
13801                deduplication_scope: "-".to_string(),
13802                fifo_throughput_limit: "-".to_string(),
13803                dead_letter_queue: "-".to_string(),
13804                messages_delayed: "0".to_string(),
13805                redrive_allow_policy: "-".to_string(),
13806                redrive_policy: "".to_string(),
13807                redrive_task_id: "-".to_string(),
13808                redrive_task_start_time: "-".to_string(),
13809                redrive_task_status: "-".to_string(),
13810                redrive_task_percent: "-".to_string(),
13811                redrive_task_destination: "-".to_string(),
13812            },
13813        ];
13814
13815        assert_eq!(app.sqs_state.queues.selected, 0);
13816        app.next_item();
13817        assert_eq!(app.sqs_state.queues.selected, 1);
13818        app.prev_item();
13819        assert_eq!(app.sqs_state.queues.selected, 0);
13820    }
13821
13822    #[test]
13823    fn test_sqs_subscriptions_navigation() {
13824        let mut app = test_app();
13825        app.service_selected = true;
13826        app.mode = Mode::Normal;
13827        app.current_service = Service::SqsQueues;
13828        app.sqs_state.current_queue = Some("test-queue".to_string());
13829        app.sqs_state.detail_tab = SqsQueueDetailTab::SnsSubscriptions;
13830        app.sqs_state.subscriptions.items = vec![
13831            SnsSubscription {
13832                subscription_arn: "arn:aws:sns:us-east-1:123:sub1".to_string(),
13833                topic_arn: "arn:aws:sns:us-east-1:123:topic1".to_string(),
13834            },
13835            SnsSubscription {
13836                subscription_arn: "arn:aws:sns:us-east-1:123:sub2".to_string(),
13837                topic_arn: "arn:aws:sns:us-east-1:123:topic2".to_string(),
13838            },
13839        ];
13840
13841        assert_eq!(app.sqs_state.subscriptions.selected, 0);
13842        app.next_item();
13843        assert_eq!(app.sqs_state.subscriptions.selected, 1);
13844        app.prev_item();
13845        assert_eq!(app.sqs_state.subscriptions.selected, 0);
13846    }
13847
13848    #[test]
13849    fn test_sqs_subscription_region_dropdown_navigation() {
13850        let mut app = test_app();
13851        app.service_selected = true;
13852        app.mode = Mode::FilterInput;
13853        app.current_service = Service::SqsQueues;
13854        app.sqs_state.current_queue = Some("test-queue".to_string());
13855        app.sqs_state.detail_tab = SqsQueueDetailTab::SnsSubscriptions;
13856        app.sqs_state.input_focus = InputFocus::Dropdown("SubscriptionRegion");
13857
13858        assert_eq!(app.sqs_state.subscription_region_selected, 0);
13859        app.next_item();
13860        assert_eq!(app.sqs_state.subscription_region_selected, 1);
13861        app.next_item();
13862        assert_eq!(app.sqs_state.subscription_region_selected, 2);
13863        app.prev_item();
13864        assert_eq!(app.sqs_state.subscription_region_selected, 1);
13865        app.prev_item();
13866        assert_eq!(app.sqs_state.subscription_region_selected, 0);
13867    }
13868
13869    #[test]
13870    fn test_sqs_subscription_region_selection() {
13871        let mut app = test_app();
13872        app.service_selected = true;
13873        app.mode = Mode::FilterInput;
13874        app.current_service = Service::SqsQueues;
13875        app.sqs_state.current_queue = Some("test-queue".to_string());
13876        app.sqs_state.detail_tab = SqsQueueDetailTab::SnsSubscriptions;
13877        app.sqs_state.input_focus = InputFocus::Dropdown("SubscriptionRegion");
13878        app.sqs_state.subscription_region_selected = 2; // us-west-1
13879
13880        assert_eq!(app.sqs_state.subscription_region_filter, "");
13881        app.handle_action(Action::ApplyFilter);
13882        assert_eq!(app.sqs_state.subscription_region_filter, "us-west-1");
13883        assert_eq!(app.mode, Mode::Normal);
13884    }
13885
13886    #[test]
13887    fn test_sqs_subscription_region_change_resets_selection() {
13888        let mut app = test_app();
13889        app.service_selected = true;
13890        app.mode = Mode::FilterInput;
13891        app.current_service = Service::SqsQueues;
13892        app.sqs_state.current_queue = Some("test-queue".to_string());
13893        app.sqs_state.detail_tab = SqsQueueDetailTab::SnsSubscriptions;
13894        app.sqs_state.input_focus = InputFocus::Dropdown("SubscriptionRegion");
13895        app.sqs_state.subscription_region_selected = 0;
13896        app.sqs_state.subscriptions.selected = 5;
13897
13898        app.handle_action(Action::NextItem);
13899
13900        assert_eq!(app.sqs_state.subscription_region_selected, 1);
13901        assert_eq!(app.sqs_state.subscriptions.selected, 0);
13902    }
13903
13904    #[test]
13905    fn test_s3_object_filter_resets_selection() {
13906        let mut app = test_app();
13907        app.service_selected = true;
13908        app.current_service = Service::S3Buckets;
13909        app.s3_state.current_bucket = Some("test-bucket".to_string());
13910        app.s3_state.selected_row = 5;
13911        app.mode = Mode::FilterInput;
13912
13913        app.handle_action(Action::CloseMenu);
13914
13915        assert_eq!(app.s3_state.selected_row, 0);
13916        assert_eq!(app.mode, Mode::Normal);
13917    }
13918
13919    #[test]
13920    fn test_s3_bucket_filter_resets_selection() {
13921        let mut app = test_app();
13922        app.service_selected = true;
13923        app.current_service = Service::S3Buckets;
13924        app.s3_state.selected_row = 10;
13925        app.mode = Mode::FilterInput;
13926
13927        app.handle_action(Action::CloseMenu);
13928
13929        assert_eq!(app.s3_state.selected_row, 0);
13930        assert_eq!(app.mode, Mode::Normal);
13931    }
13932
13933    #[test]
13934    fn test_s3_selection_stays_in_bounds() {
13935        let mut app = test_app();
13936        app.service_selected = true;
13937        app.current_service = Service::S3Buckets;
13938        app.s3_state.selected_row = 0;
13939        app.s3_state.selected_object = 0;
13940
13941        // Simulate going up from row 0
13942        app.prev_item();
13943
13944        // Should stay at 0, not wrap to negative
13945        assert_eq!(app.s3_state.selected_row, 0);
13946        assert_eq!(app.s3_state.selected_object, 0);
13947    }
13948
13949    #[test]
13950    fn test_cfn_filter_resets_selection() {
13951        let mut app = test_app();
13952        app.service_selected = true;
13953        app.current_service = Service::CloudFormationStacks;
13954        app.cfn_state.table.selected = 10;
13955        app.mode = Mode::FilterInput;
13956
13957        app.handle_action(Action::CloseMenu);
13958
13959        assert_eq!(app.cfn_state.table.selected, 0);
13960        assert_eq!(app.mode, Mode::Normal);
13961    }
13962
13963    #[test]
13964    fn test_lambda_filter_resets_selection() {
13965        let mut app = test_app();
13966        app.service_selected = true;
13967        app.current_service = Service::LambdaFunctions;
13968        app.lambda_state.table.selected = 8;
13969        app.mode = Mode::FilterInput;
13970
13971        app.handle_action(Action::CloseMenu);
13972
13973        assert_eq!(app.lambda_state.table.selected, 0);
13974        assert_eq!(app.mode, Mode::Normal);
13975    }
13976
13977    #[test]
13978    fn test_sqs_filter_resets_selection() {
13979        let mut app = test_app();
13980        app.service_selected = true;
13981        app.current_service = Service::SqsQueues;
13982        app.sqs_state.queues.selected = 7;
13983        app.mode = Mode::FilterInput;
13984
13985        app.handle_action(Action::CloseMenu);
13986
13987        assert_eq!(app.sqs_state.queues.selected, 0);
13988        assert_eq!(app.mode, Mode::Normal);
13989    }
13990
13991    #[test]
13992    fn test_sqs_queues_list_shows_preferences() {
13993        let mut app = test_app();
13994        app.service_selected = true;
13995        app.current_service = Service::SqsQueues;
13996        app.mode = Mode::Normal;
13997
13998        app.handle_action(Action::OpenColumnSelector);
13999
14000        assert_eq!(app.mode, Mode::ColumnSelector);
14001    }
14002
14003    #[test]
14004    fn test_sqs_queue_policies_tab_no_preferences() {
14005        let mut app = test_app();
14006        app.service_selected = true;
14007        app.current_service = Service::SqsQueues;
14008        app.sqs_state.current_queue = Some("test-queue".to_string());
14009        app.sqs_state.detail_tab = SqsQueueDetailTab::QueuePolicies;
14010        app.mode = Mode::Normal;
14011
14012        app.handle_action(Action::OpenColumnSelector);
14013
14014        assert_eq!(app.mode, Mode::Normal);
14015    }
14016
14017    #[test]
14018    fn test_sqs_sns_subscriptions_tab_shows_preferences() {
14019        let mut app = test_app();
14020        app.service_selected = true;
14021        app.current_service = Service::SqsQueues;
14022        app.sqs_state.current_queue = Some("test-queue".to_string());
14023        app.sqs_state.detail_tab = SqsQueueDetailTab::SnsSubscriptions;
14024        app.mode = Mode::Normal;
14025
14026        app.handle_action(Action::OpenColumnSelector);
14027
14028        assert_eq!(app.mode, Mode::ColumnSelector);
14029    }
14030
14031    #[test]
14032    fn test_sqs_monitoring_tab_no_preferences() {
14033        let mut app = test_app();
14034        app.service_selected = true;
14035        app.current_service = Service::SqsQueues;
14036        app.sqs_state.current_queue = Some("test-queue".to_string());
14037        app.sqs_state.detail_tab = SqsQueueDetailTab::Monitoring;
14038        app.mode = Mode::Normal;
14039
14040        app.handle_action(Action::OpenColumnSelector);
14041
14042        assert_eq!(app.mode, Mode::Normal);
14043    }
14044
14045    #[test]
14046    fn test_cfn_status_filter_change_resets_selection() {
14047        use crate::ui::cfn::STATUS_FILTER;
14048        let mut app = test_app();
14049        app.service_selected = true;
14050        app.current_service = Service::CloudFormationStacks;
14051        app.mode = Mode::FilterInput;
14052        app.cfn_state.input_focus = STATUS_FILTER;
14053        app.cfn_state.status_filter = CfnStatusFilter::All;
14054        app.cfn_state.table.items = vec![
14055            CfnStack {
14056                name: "stack1".to_string(),
14057                stack_id: "id1".to_string(),
14058                status: "CREATE_COMPLETE".to_string(),
14059                created_time: "2024-01-01".to_string(),
14060                updated_time: String::new(),
14061                deleted_time: String::new(),
14062                drift_status: String::new(),
14063                last_drift_check_time: String::new(),
14064                status_reason: String::new(),
14065                description: String::new(),
14066                detailed_status: String::new(),
14067                root_stack: String::new(),
14068                parent_stack: String::new(),
14069                termination_protection: false,
14070                iam_role: String::new(),
14071                tags: Vec::new(),
14072                stack_policy: String::new(),
14073                rollback_monitoring_time: String::new(),
14074                rollback_alarms: Vec::new(),
14075                notification_arns: Vec::new(),
14076            },
14077            CfnStack {
14078                name: "stack2".to_string(),
14079                stack_id: "id2".to_string(),
14080                status: "UPDATE_IN_PROGRESS".to_string(),
14081                created_time: "2024-01-02".to_string(),
14082                updated_time: String::new(),
14083                deleted_time: String::new(),
14084                drift_status: String::new(),
14085                last_drift_check_time: String::new(),
14086                status_reason: String::new(),
14087                description: String::new(),
14088                detailed_status: String::new(),
14089                root_stack: String::new(),
14090                parent_stack: String::new(),
14091                termination_protection: false,
14092                iam_role: String::new(),
14093                tags: Vec::new(),
14094                stack_policy: String::new(),
14095                rollback_monitoring_time: String::new(),
14096                rollback_alarms: Vec::new(),
14097                notification_arns: Vec::new(),
14098            },
14099        ];
14100        app.cfn_state.table.selected = 1;
14101
14102        app.handle_action(Action::NextItem);
14103
14104        assert_eq!(app.cfn_state.status_filter, CfnStatusFilter::Active);
14105        assert_eq!(app.cfn_state.table.selected, 0);
14106    }
14107
14108    #[test]
14109    fn test_cfn_view_nested_toggle_resets_selection() {
14110        use crate::ui::cfn::VIEW_NESTED;
14111        let mut app = test_app();
14112        app.service_selected = true;
14113        app.current_service = Service::CloudFormationStacks;
14114        app.mode = Mode::FilterInput;
14115        app.cfn_state.input_focus = VIEW_NESTED;
14116        app.cfn_state.view_nested = false;
14117        app.cfn_state.table.items = vec![CfnStack {
14118            name: "stack1".to_string(),
14119            stack_id: "id1".to_string(),
14120            status: "CREATE_COMPLETE".to_string(),
14121            created_time: "2024-01-01".to_string(),
14122            updated_time: String::new(),
14123            deleted_time: String::new(),
14124            drift_status: String::new(),
14125            last_drift_check_time: String::new(),
14126            status_reason: String::new(),
14127            description: String::new(),
14128            detailed_status: String::new(),
14129            root_stack: String::new(),
14130            parent_stack: String::new(),
14131            termination_protection: false,
14132            iam_role: String::new(),
14133            tags: Vec::new(),
14134            stack_policy: String::new(),
14135            rollback_monitoring_time: String::new(),
14136            rollback_alarms: Vec::new(),
14137            notification_arns: Vec::new(),
14138        }];
14139        app.cfn_state.table.selected = 5;
14140
14141        app.handle_action(Action::ToggleFilterCheckbox);
14142
14143        assert!(app.cfn_state.view_nested, "view_nested must be toggled on");
14144        assert_eq!(app.cfn_state.table.selected, 0, "selection must reset");
14145        // Regression fix: toggling view_nested must clear items and set loading=true
14146        // so main.rs triggers a reload with the new include_nested value.
14147        // Items stay visible during reload (no flash of empty)
14148        assert!(
14149            app.cfn_state.table.loading,
14150            "loading must be set to trigger reload in main.rs"
14151        );
14152        assert!(
14153            app.cfn_state.table.loading,
14154            "loading must be set to trigger reload in main.rs"
14155        );
14156    }
14157
14158    #[test]
14159    fn test_cfn_view_nested_toggle_off_also_triggers_reload() {
14160        // Toggling view_nested OFF must also reload (hides nested stacks).
14161        use crate::ui::cfn::VIEW_NESTED;
14162        let mut app = test_app();
14163        app.service_selected = true;
14164        app.current_service = Service::CloudFormationStacks;
14165        app.mode = Mode::FilterInput;
14166        app.cfn_state.input_focus = VIEW_NESTED;
14167        app.cfn_state.view_nested = true; // already on, toggling off
14168        app.cfn_state.table.items = vec![CfnStack {
14169            name: "nested-stack".to_string(),
14170            stack_id: "id2".to_string(),
14171            status: "CREATE_COMPLETE".to_string(),
14172            created_time: "2024-01-01".to_string(),
14173            updated_time: String::new(),
14174            deleted_time: String::new(),
14175            drift_status: String::new(),
14176            last_drift_check_time: String::new(),
14177            status_reason: String::new(),
14178            description: String::new(),
14179            detailed_status: String::new(),
14180            root_stack: String::new(),
14181            parent_stack: String::new(),
14182            termination_protection: false,
14183            iam_role: String::new(),
14184            tags: Vec::new(),
14185            stack_policy: String::new(),
14186            rollback_monitoring_time: String::new(),
14187            rollback_alarms: Vec::new(),
14188            notification_arns: Vec::new(),
14189        }];
14190
14191        app.handle_action(Action::ToggleFilterCheckbox);
14192
14193        assert!(
14194            !app.cfn_state.view_nested,
14195            "view_nested must be toggled off"
14196        );
14197        // Items stay visible during reload
14198        assert!(
14199            !app.cfn_state.table.items.is_empty(),
14200            "items must stay visible during reload (no flash)"
14201        );
14202        assert!(
14203            app.cfn_state.table.loading,
14204            "loading must be set to trigger reload"
14205        );
14206    }
14207
14208    #[test]
14209    fn test_cfn_view_nested_hierarchical_shows_children_when_expanded() {
14210        // When view_nested=true and a parent stack is expanded, its children
14211        // must appear immediately after the parent in filtered_cloudformation_stacks.
14212        use crate::ui::cfn::filtered_cloudformation_stacks;
14213
14214        let mut app = test_app();
14215        app.current_service = Service::CloudFormationStacks;
14216        app.service_selected = true;
14217        app.cfn_state.view_nested = true;
14218
14219        let parent_id = "arn:aws:cloudformation:us-east-1:123:stack/parent/abc123".to_string();
14220        let child_id =
14221            "arn:aws:cloudformation:us-east-1:123:stack/parent-nested/def456".to_string();
14222
14223        app.cfn_state.table.items = vec![
14224            CfnStack {
14225                name: "parent-stack".to_string(),
14226                stack_id: parent_id.clone(),
14227                status: "CREATE_COMPLETE".to_string(),
14228                parent_stack: String::new(), // no parent = root
14229                root_stack: parent_id.clone(),
14230                created_time: "2024-01-02".to_string(),
14231                ..Default::default()
14232            },
14233            CfnStack {
14234                name: "child-stack".to_string(),
14235                stack_id: child_id.clone(),
14236                status: "CREATE_COMPLETE".to_string(),
14237                parent_stack: parent_id.clone(), // parent = root stack
14238                root_stack: parent_id.clone(),
14239                created_time: "2024-01-01".to_string(),
14240                ..Default::default()
14241            },
14242        ];
14243
14244        // With view_nested=true: both root and child visible (expand all by default)
14245        let filtered = filtered_cloudformation_stacks(&app);
14246        assert_eq!(
14247            filtered.len(),
14248            2,
14249            "Root + child shown when view_nested=true"
14250        );
14251        assert_eq!(filtered[0].name, "parent-stack", "Root first");
14252        assert_eq!(filtered[1].name, "child-stack", "Child after root");
14253    }
14254
14255    #[test]
14256    fn test_cfn_template_scroll_up() {
14257        let mut app = test_app();
14258        app.service_selected = true;
14259        app.current_service = Service::CloudFormationStacks;
14260        app.cfn_state.current_stack = Some("test-stack".to_string());
14261        app.cfn_state.detail_tab = CfnDetailTab::Template;
14262        app.cfn_state.template_scroll = 20;
14263
14264        app.page_up();
14265
14266        assert_eq!(app.cfn_state.template_scroll, 10);
14267    }
14268
14269    #[test]
14270    fn test_cfn_template_scroll_down() {
14271        let mut app = test_app();
14272        app.service_selected = true;
14273        app.current_service = Service::CloudFormationStacks;
14274        app.cfn_state.current_stack = Some("test-stack".to_string());
14275        app.cfn_state.detail_tab = CfnDetailTab::Template;
14276        app.cfn_state.template_body = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15".to_string();
14277        app.cfn_state.template_scroll = 0;
14278
14279        app.page_down();
14280
14281        assert_eq!(app.cfn_state.template_scroll, 10);
14282    }
14283
14284    #[test]
14285    fn test_cfn_template_scroll_down_respects_max() {
14286        let mut app = test_app();
14287        app.service_selected = true;
14288        app.current_service = Service::CloudFormationStacks;
14289        app.cfn_state.current_stack = Some("test-stack".to_string());
14290        app.cfn_state.detail_tab = CfnDetailTab::Template;
14291        app.cfn_state.template_body = "line1\nline2\nline3".to_string();
14292        app.cfn_state.template_scroll = 0;
14293
14294        app.page_down();
14295
14296        // Should not scroll past the last line (3 lines = max scroll of 2)
14297        assert_eq!(app.cfn_state.template_scroll, 2);
14298    }
14299
14300    #[test]
14301    fn test_cfn_template_arrow_up() {
14302        let mut app = test_app();
14303        app.service_selected = true;
14304        app.current_service = Service::CloudFormationStacks;
14305        app.mode = Mode::Normal;
14306        app.cfn_state.current_stack = Some("test-stack".to_string());
14307        app.cfn_state.detail_tab = CfnDetailTab::Template;
14308        app.cfn_state.template_scroll = 5;
14309
14310        app.prev_item();
14311
14312        assert_eq!(app.cfn_state.template_scroll, 4);
14313    }
14314
14315    #[test]
14316    fn test_cfn_template_arrow_down() {
14317        let mut app = test_app();
14318        app.service_selected = true;
14319        app.current_service = Service::CloudFormationStacks;
14320        app.mode = Mode::Normal;
14321        app.cfn_state.current_stack = Some("test-stack".to_string());
14322        app.cfn_state.detail_tab = CfnDetailTab::Template;
14323        app.cfn_state.template_body = "line1\nline2\nline3\nline4\nline5".to_string();
14324        app.cfn_state.template_scroll = 2;
14325
14326        app.next_item();
14327
14328        assert_eq!(app.cfn_state.template_scroll, 3);
14329    }
14330
14331    #[test]
14332    fn test_cfn_template_arrow_down_respects_max() {
14333        let mut app = test_app();
14334        app.service_selected = true;
14335        app.current_service = Service::CloudFormationStacks;
14336        app.mode = Mode::Normal;
14337        app.cfn_state.current_stack = Some("test-stack".to_string());
14338        app.cfn_state.detail_tab = CfnDetailTab::Template;
14339        app.cfn_state.template_body = "line1\nline2".to_string();
14340        app.cfn_state.template_scroll = 1;
14341
14342        app.next_item();
14343
14344        // Should stay at max (2 lines = max scroll of 1)
14345        assert_eq!(app.cfn_state.template_scroll, 1);
14346    }
14347
14348    #[test]
14349    fn test_cfn_yank_on_template_tab_copies_template_body() {
14350        // Regression: yank() always copied stack_id regardless of active tab.
14351        // On Template tab it must copy the template body.
14352        let mut app = test_app();
14353        app.current_service = Service::CloudFormationStacks;
14354        app.cfn_state.current_stack = Some("my-stack".to_string());
14355        app.cfn_state.detail_tab = CfnDetailTab::Template;
14356        app.cfn_state.template_body = "AWSTemplateFormatVersion: '2010-09-09'".to_string();
14357        // The yank function should not panic and should handle template tab.
14358        // We can't assert clipboard contents in tests, so verify it doesn't
14359        // call stack_id path by ensuring template_body is non-empty.
14360        assert!(!app.cfn_state.template_body.is_empty());
14361        crate::cfn::actions::yank(&app); // must not panic
14362    }
14363
14364    #[test]
14365    fn test_cfn_change_sets_console_url_includes_events_view_graph() {
14366        use crate::cfn::console_url_stack_detail_with_tab;
14367        let url = console_url_stack_detail_with_tab(
14368            "us-east-1",
14369            "arn:aws:cloudformation:us-east-1:123456789012:stack/my-stack/abc123",
14370            &CfnDetailTab::ChangeSets,
14371        );
14372        assert!(
14373            url.contains("eventsView=graph"),
14374            "ChangeSets URL must include eventsView=graph, got: {url}"
14375        );
14376        assert!(url.contains("changesets"), "URL must contain 'changesets'");
14377    }
14378
14379    #[test]
14380    fn test_cfn_other_tabs_console_url_no_events_view_graph() {
14381        use crate::cfn::console_url_stack_detail_with_tab;
14382        for tab in [
14383            CfnDetailTab::StackInfo,
14384            CfnDetailTab::Events,
14385            CfnDetailTab::Resources,
14386        ] {
14387            let url = console_url_stack_detail_with_tab(
14388                "us-east-1",
14389                "arn:aws:cloudformation:us-east-1:123456789012:stack/my-stack/abc123",
14390                &tab,
14391            );
14392            assert!(
14393                !url.contains("eventsView=graph"),
14394                "{tab:?} URL must NOT include eventsView=graph, got: {url}"
14395            );
14396        }
14397    }
14398
14399    #[test]
14400    fn test_cfn_change_sets_expand_collapse() {
14401        let mut app = test_app();
14402        app.current_service = Service::CloudFormationStacks;
14403        app.service_selected = true;
14404        app.cfn_state.current_stack = Some("my-stack".to_string());
14405        app.cfn_state.detail_tab = CfnDetailTab::ChangeSets;
14406        app.cfn_state.change_sets.items = vec![rusticity_core::cfn::StackChangeSet {
14407            name: "cs-001".to_string(),
14408            change_set_id: "id-001".to_string(),
14409            created_time: String::new(),
14410            status: "CREATE_COMPLETE".to_string(),
14411            description: String::new(),
14412            root_change_set_id: String::new(),
14413            parent_change_set_id: String::new(),
14414        }];
14415        app.cfn_state.change_sets.selected = 0;
14416
14417        assert_eq!(app.cfn_state.change_sets.expanded_item, None);
14418        app.handle_action(Action::ExpandRow);
14419        assert_eq!(
14420            app.cfn_state.change_sets.expanded_item,
14421            Some(0),
14422            "Enter must expand change set row"
14423        );
14424        app.handle_action(Action::CollapseRow);
14425        assert_eq!(
14426            app.cfn_state.change_sets.expanded_item, None,
14427            "Left arrow must collapse change set row"
14428        );
14429    }
14430
14431    #[test]
14432    fn test_cfn_events_toggle_column_hides_visible_column() {
14433        // Regression: the inline toggle_column block in app.rs had no Events or
14434        // ChangeSets branch — ToggleColumn on Events tab was a no-op.
14435        let mut app = test_app();
14436        app.current_service = Service::CloudFormationStacks;
14437        app.mode = Mode::ColumnSelector;
14438        app.cfn_state.current_stack = Some("my-stack".to_string());
14439        app.cfn_state.detail_tab = CfnDetailTab::Events;
14440
14441        // column_selector_index 1 = first column (OperationId, which is visible by default)
14442        let first_col = app.cfn_event_column_ids[0];
14443        assert!(
14444            app.cfn_event_visible_column_ids.contains(&first_col),
14445            "First column must be visible before toggle"
14446        );
14447
14448        app.column_selector_index = 1; // index 1 = first column
14449        app.handle_action(Action::ToggleColumn);
14450
14451        assert!(
14452            !app.cfn_event_visible_column_ids.contains(&first_col),
14453            "ToggleColumn must hide the first Events column"
14454        );
14455
14456        // Toggle again → should re-add it
14457        app.handle_action(Action::ToggleColumn);
14458        assert!(
14459            app.cfn_event_visible_column_ids.contains(&first_col),
14460            "ToggleColumn again must re-show the column"
14461        );
14462    }
14463
14464    #[test]
14465    fn test_cfn_events_toggle_column_keeps_at_least_one_visible() {
14466        // At least 1 column must remain visible
14467        let mut app = test_app();
14468        app.current_service = Service::CloudFormationStacks;
14469        app.mode = Mode::ColumnSelector;
14470        app.cfn_state.current_stack = Some("my-stack".to_string());
14471        app.cfn_state.detail_tab = CfnDetailTab::Events;
14472
14473        // Force only 1 visible column
14474        app.cfn_event_visible_column_ids = vec![app.cfn_event_column_ids[0]];
14475        app.column_selector_index = 1;
14476
14477        app.handle_action(Action::ToggleColumn);
14478
14479        // Must still have at least 1
14480        assert!(
14481            !app.cfn_event_visible_column_ids.is_empty(),
14482            "At least one column must remain visible"
14483        );
14484    }
14485
14486    #[test]
14487    fn test_space_menu_o_opens_service_picker() {
14488        // Verify the full flow: SpaceMenu → 'o' → ServicePicker mode
14489        let mut app = test_app();
14490        app.handle_action(Action::OpenSpaceMenu);
14491        assert_eq!(app.mode, Mode::SpaceMenu);
14492
14493        app.handle_action(Action::OpenServicePicker);
14494        assert_eq!(
14495            app.mode,
14496            Mode::ServicePicker,
14497            "'o' from SpaceMenu must open ServicePicker"
14498        );
14499    }
14500
14501    #[test]
14502    fn test_space_menu_c_closes_service() {
14503        let mut app = test_app();
14504        app.current_service = Service::CloudFormationStacks;
14505        app.service_selected = true;
14506        app.tabs.push(Tab {
14507            service: Service::CloudFormationStacks,
14508            title: "CFN".to_string(),
14509            breadcrumb: "CloudFormation".to_string(),
14510        });
14511        app.current_tab = 0;
14512
14513        app.handle_action(Action::OpenSpaceMenu);
14514        app.handle_action(Action::CloseService);
14515        // Mode should return to normal
14516        assert_ne!(
14517            app.mode,
14518            Mode::SpaceMenu,
14519            "CloseService must exit SpaceMenu"
14520        );
14521    }
14522}
14523
14524#[cfg(test)]
14525mod lambda_version_tab_tests {
14526    use super::*;
14527    use crate::ui::iam::POLICY_TYPE_DROPDOWN;
14528    use test_helpers::*;
14529
14530    #[test]
14531    fn test_lambda_version_tab_cycling_next() {
14532        let mut app = test_app();
14533        app.current_service = Service::LambdaFunctions;
14534        app.lambda_state.current_function = Some("test-function".to_string());
14535        app.lambda_state.current_version = Some("1".to_string());
14536        app.lambda_state.detail_tab = LambdaDetailTab::Code;
14537
14538        // Code -> Monitor
14539        app.handle_action(Action::NextDetailTab);
14540        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Monitor);
14541        assert!(app.lambda_state.metrics_loading);
14542
14543        // Monitor -> Configuration
14544        app.lambda_state.metrics_loading = false;
14545        app.handle_action(Action::NextDetailTab);
14546        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Configuration);
14547
14548        // Configuration -> Code
14549        app.handle_action(Action::NextDetailTab);
14550        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
14551    }
14552
14553    #[test]
14554    fn test_lambda_version_tab_cycling_prev() {
14555        let mut app = test_app();
14556        app.current_service = Service::LambdaFunctions;
14557        app.lambda_state.current_function = Some("test-function".to_string());
14558        app.lambda_state.current_version = Some("1".to_string());
14559        app.lambda_state.detail_tab = LambdaDetailTab::Code;
14560
14561        // Code -> Configuration
14562        app.handle_action(Action::PrevDetailTab);
14563        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Configuration);
14564
14565        // Configuration -> Monitor
14566        app.handle_action(Action::PrevDetailTab);
14567        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Monitor);
14568        assert!(app.lambda_state.metrics_loading);
14569
14570        // Monitor -> Code
14571        app.lambda_state.metrics_loading = false;
14572        app.handle_action(Action::PrevDetailTab);
14573        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Code);
14574    }
14575
14576    #[test]
14577    fn test_lambda_version_monitor_clears_metrics() {
14578        let mut app = test_app();
14579        app.current_service = Service::LambdaFunctions;
14580        app.lambda_state.current_function = Some("test-function".to_string());
14581        app.lambda_state.current_version = Some("1".to_string());
14582        app.lambda_state.detail_tab = LambdaDetailTab::Code;
14583
14584        // Add some fake metric data
14585        app.lambda_state.metric_data_invocations = vec![(1, 10.0), (2, 20.0)];
14586        app.lambda_state.monitoring_scroll = 5;
14587
14588        // Switch to Monitor tab
14589        app.handle_action(Action::NextDetailTab);
14590
14591        assert_eq!(app.lambda_state.detail_tab, LambdaDetailTab::Monitor);
14592        assert!(app.lambda_state.metrics_loading);
14593        assert_eq!(app.lambda_state.monitoring_scroll, 0);
14594        assert!(app.lambda_state.metric_data_invocations.is_empty());
14595    }
14596
14597    #[test]
14598    fn test_cfn_parameters_expand_collapse() {
14599        let mut app = test_app();
14600        app.current_service = Service::CloudFormationStacks;
14601        app.service_selected = true;
14602        app.cfn_state.current_stack = Some("test-stack".to_string());
14603        app.cfn_state.detail_tab = CfnDetailTab::Parameters;
14604        app.cfn_state.parameters.items = vec![rusticity_core::cfn::StackParameter {
14605            key: "Param1".to_string(),
14606            value: "Value1".to_string(),
14607            resolved_value: "Resolved1".to_string(),
14608        }];
14609        app.cfn_state.parameters.reset();
14610
14611        assert_eq!(app.cfn_state.parameters.expanded_item, None);
14612
14613        // Right arrow expands
14614        app.handle_action(Action::NextPane);
14615        assert_eq!(app.cfn_state.parameters.expanded_item, Some(0));
14616
14617        // Left arrow collapses
14618        app.handle_action(Action::PrevPane);
14619        assert_eq!(app.cfn_state.parameters.expanded_item, None);
14620    }
14621
14622    #[test]
14623    fn test_cfn_parameters_filter_resets_selection() {
14624        let mut app = test_app();
14625        app.current_service = Service::CloudFormationStacks;
14626        app.service_selected = true;
14627        app.cfn_state.current_stack = Some("test-stack".to_string());
14628        app.cfn_state.detail_tab = CfnDetailTab::Parameters;
14629        app.cfn_state.parameters.items = vec![
14630            rusticity_core::cfn::StackParameter {
14631                key: "DatabaseName".to_string(),
14632                value: "mydb".to_string(),
14633                resolved_value: "mydb".to_string(),
14634            },
14635            rusticity_core::cfn::StackParameter {
14636                key: "InstanceType".to_string(),
14637                value: "t2.micro".to_string(),
14638                resolved_value: "t2.micro".to_string(),
14639            },
14640            rusticity_core::cfn::StackParameter {
14641                key: "Environment".to_string(),
14642                value: "production".to_string(),
14643                resolved_value: "production".to_string(),
14644            },
14645        ];
14646        app.cfn_state.parameters.selected = 2; // Select third item
14647        app.mode = Mode::FilterInput;
14648        app.cfn_state.parameters_input_focus = InputFocus::Filter;
14649
14650        // Type a filter character - should reset selection
14651        app.handle_action(Action::FilterInput('D'));
14652        assert_eq!(app.cfn_state.parameters.selected, 0);
14653        assert_eq!(app.cfn_state.parameters.filter, "D");
14654
14655        // Select another item
14656        app.cfn_state.parameters.selected = 1;
14657
14658        // Type another character - should reset again
14659        app.handle_action(Action::FilterInput('a'));
14660        assert_eq!(app.cfn_state.parameters.selected, 0);
14661        assert_eq!(app.cfn_state.parameters.filter, "Da");
14662
14663        // Select another item
14664        app.cfn_state.parameters.selected = 1;
14665
14666        // Backspace - should also reset
14667        app.handle_action(Action::FilterBackspace);
14668        assert_eq!(app.cfn_state.parameters.selected, 0);
14669        assert_eq!(app.cfn_state.parameters.filter, "D");
14670    }
14671
14672    #[test]
14673    fn test_cfn_template_tab_no_preferences() {
14674        let mut app = test_app();
14675        app.current_service = Service::CloudFormationStacks;
14676        app.service_selected = true;
14677        app.cfn_state.current_stack = Some("test-stack".to_string());
14678        app.cfn_state.detail_tab = CfnDetailTab::Template;
14679        app.mode = Mode::Normal;
14680
14681        // Try to open preferences - should be ignored
14682        app.handle_action(Action::OpenColumnSelector);
14683        assert_eq!(app.mode, Mode::Normal); // Should stay in Normal mode
14684
14685        // GitSync tab should also not allow preferences
14686        app.cfn_state.detail_tab = CfnDetailTab::GitSync;
14687        app.handle_action(Action::OpenColumnSelector);
14688        assert_eq!(app.mode, Mode::Normal); // Should stay in Normal mode
14689
14690        // Parameters tab should allow preferences
14691        app.cfn_state.detail_tab = CfnDetailTab::Parameters;
14692        app.handle_action(Action::OpenColumnSelector);
14693        assert_eq!(app.mode, Mode::ColumnSelector); // Should open preferences
14694
14695        // Outputs tab should allow preferences
14696        app.mode = Mode::Normal;
14697        app.cfn_state.detail_tab = CfnDetailTab::Outputs;
14698        app.handle_action(Action::OpenColumnSelector);
14699        assert_eq!(app.mode, Mode::ColumnSelector); // Should open preferences
14700    }
14701
14702    #[test]
14703    fn test_iam_user_groups_tab_shows_preferences() {
14704        let mut app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
14705        app.current_service = Service::IamUsers;
14706        app.service_selected = true;
14707        app.mode = Mode::Normal;
14708        app.iam_state.current_user = Some("test-user".to_string());
14709        app.iam_state.user_tab = UserTab::Groups;
14710
14711        // Should allow opening preferences
14712        app.handle_action(Action::OpenColumnSelector);
14713        assert_eq!(app.mode, Mode::ColumnSelector);
14714    }
14715
14716    #[test]
14717    fn test_iam_user_tags_tab_shows_preferences() {
14718        let mut app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
14719        app.current_service = Service::IamUsers;
14720        app.service_selected = true;
14721        app.mode = Mode::Normal;
14722        app.iam_state.current_user = Some("test-user".to_string());
14723        app.iam_state.user_tab = UserTab::Tags;
14724
14725        // Should allow opening preferences
14726        app.handle_action(Action::OpenColumnSelector);
14727        assert_eq!(app.mode, Mode::ColumnSelector);
14728    }
14729
14730    #[test]
14731    fn test_iam_user_last_accessed_tab_shows_preferences() {
14732        let mut app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
14733        app.current_service = Service::IamUsers;
14734        app.service_selected = true;
14735        app.mode = Mode::Normal;
14736        app.iam_state.current_user = Some("test-user".to_string());
14737        app.iam_state.user_tab = UserTab::LastAccessed;
14738
14739        // Should allow opening preferences
14740        app.handle_action(Action::OpenColumnSelector);
14741        assert_eq!(app.mode, Mode::ColumnSelector);
14742    }
14743
14744    #[test]
14745    fn test_iam_user_security_credentials_tab_no_preferences() {
14746        let mut app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
14747        app.current_service = Service::IamUsers;
14748        app.service_selected = true;
14749        app.mode = Mode::Normal;
14750        app.iam_state.current_user = Some("test-user".to_string());
14751        app.iam_state.user_tab = UserTab::SecurityCredentials;
14752
14753        // Should NOT allow opening preferences
14754        app.handle_action(Action::OpenColumnSelector);
14755        assert_eq!(app.mode, Mode::Normal);
14756    }
14757
14758    #[test]
14759    fn test_iam_user_tabs_without_column_preferences() {
14760        let mut app = test_app();
14761        app.current_service = Service::IamUsers;
14762        app.service_selected = true;
14763        app.iam_state.current_user = Some("test-user".to_string());
14764        app.mode = Mode::Normal;
14765
14766        // Groups tab should allow preferences (page size)
14767        app.iam_state.user_tab = UserTab::Groups;
14768        app.handle_action(Action::OpenColumnSelector);
14769        assert_eq!(app.mode, Mode::ColumnSelector);
14770        app.mode = Mode::Normal;
14771
14772        // Tags tab should allow preferences (page size)
14773        app.iam_state.user_tab = UserTab::Tags;
14774        app.handle_action(Action::OpenColumnSelector);
14775        assert_eq!(app.mode, Mode::ColumnSelector);
14776        app.mode = Mode::Normal;
14777
14778        // SecurityCredentials tab should not allow preferences
14779        app.iam_state.user_tab = UserTab::SecurityCredentials;
14780        app.handle_action(Action::OpenColumnSelector);
14781        assert_eq!(app.mode, Mode::Normal);
14782
14783        // LastAccessed tab should allow preferences (page size)
14784        app.iam_state.user_tab = UserTab::LastAccessed;
14785        app.handle_action(Action::OpenColumnSelector);
14786        assert_eq!(app.mode, Mode::ColumnSelector);
14787        app.mode = Mode::Normal;
14788
14789        // Permissions tab should allow preferences
14790        app.iam_state.user_tab = UserTab::Permissions;
14791        app.handle_action(Action::OpenColumnSelector);
14792        assert_eq!(app.mode, Mode::ColumnSelector);
14793
14794        // User list (no current_user) should allow preferences
14795        app.mode = Mode::Normal;
14796        app.iam_state.current_user = None;
14797        app.handle_action(Action::OpenColumnSelector);
14798        assert_eq!(app.mode, Mode::ColumnSelector);
14799    }
14800
14801    #[test]
14802    fn test_iam_role_policies_dropdown_cycling() {
14803        let mut app = test_app();
14804        app.current_service = Service::IamRoles;
14805        app.service_selected = true;
14806        app.iam_state.current_role = Some("test-role".to_string());
14807        app.iam_state.role_tab = RoleTab::Permissions;
14808        app.mode = Mode::FilterInput;
14809        app.iam_state.policy_input_focus = POLICY_TYPE_DROPDOWN;
14810        app.iam_state.policy_type_filter = "All types".to_string();
14811
14812        // Test next cycling
14813        app.next_item();
14814        assert_eq!(app.iam_state.policy_type_filter, "AWS managed");
14815        app.next_item();
14816        assert_eq!(app.iam_state.policy_type_filter, "Customer managed");
14817        app.next_item();
14818        assert_eq!(app.iam_state.policy_type_filter, "All types");
14819
14820        // Test prev cycling
14821        app.prev_item();
14822        assert_eq!(app.iam_state.policy_type_filter, "Customer managed");
14823        app.prev_item();
14824        assert_eq!(app.iam_state.policy_type_filter, "AWS managed");
14825        app.prev_item();
14826        assert_eq!(app.iam_state.policy_type_filter, "All types");
14827    }
14828
14829    #[test]
14830    fn test_iam_user_policies_dropdown_cycling() {
14831        let mut app = test_app();
14832        app.current_service = Service::IamUsers;
14833        app.service_selected = true;
14834        app.iam_state.current_user = Some("test-user".to_string());
14835        app.iam_state.user_tab = UserTab::Permissions;
14836        app.mode = Mode::FilterInput;
14837        app.iam_state.policy_input_focus = POLICY_TYPE_DROPDOWN;
14838        app.iam_state.policy_type_filter = "All types".to_string();
14839
14840        // Test next cycling
14841        app.next_item();
14842        assert_eq!(app.iam_state.policy_type_filter, "AWS managed");
14843        app.next_item();
14844        assert_eq!(app.iam_state.policy_type_filter, "Customer managed");
14845        app.next_item();
14846        assert_eq!(app.iam_state.policy_type_filter, "All types");
14847
14848        // Test prev cycling
14849        app.prev_item();
14850        assert_eq!(app.iam_state.policy_type_filter, "Customer managed");
14851        app.prev_item();
14852        assert_eq!(app.iam_state.policy_type_filter, "AWS managed");
14853        app.prev_item();
14854        assert_eq!(app.iam_state.policy_type_filter, "All types");
14855    }
14856
14857    #[test]
14858    fn test_iam_role_tabs_without_column_preferences() {
14859        let mut app = test_app();
14860        app.current_service = Service::IamRoles;
14861        app.service_selected = true;
14862        app.iam_state.current_role = Some("test-role".to_string());
14863        app.mode = Mode::Normal;
14864
14865        // TrustRelationships tab should not allow preferences
14866        app.iam_state.role_tab = RoleTab::TrustRelationships;
14867        app.handle_action(Action::OpenColumnSelector);
14868        assert_eq!(app.mode, Mode::Normal);
14869
14870        // RevokeSessions tab should not allow preferences
14871        app.iam_state.role_tab = RoleTab::RevokeSessions;
14872        app.handle_action(Action::OpenColumnSelector);
14873        assert_eq!(app.mode, Mode::Normal);
14874
14875        // LastAccessed tab should allow preferences
14876        app.iam_state.role_tab = RoleTab::LastAccessed;
14877        app.handle_action(Action::OpenColumnSelector);
14878        assert_eq!(app.mode, Mode::ColumnSelector);
14879
14880        // Permissions tab should allow preferences
14881        app.mode = Mode::Normal;
14882        app.iam_state.role_tab = RoleTab::Permissions;
14883        app.handle_action(Action::OpenColumnSelector);
14884        assert_eq!(app.mode, Mode::ColumnSelector);
14885
14886        // Tags tab should allow preferences
14887        app.mode = Mode::Normal;
14888        app.iam_state.role_tab = RoleTab::Tags;
14889        app.handle_action(Action::OpenColumnSelector);
14890        assert_eq!(app.mode, Mode::ColumnSelector);
14891
14892        // Role list (no current_role) should allow preferences
14893        app.mode = Mode::Normal;
14894        app.iam_state.current_role = None;
14895        app.handle_action(Action::OpenColumnSelector);
14896        assert_eq!(app.mode, Mode::ColumnSelector);
14897    }
14898
14899    #[test]
14900    fn test_iam_role_tags_tab_cycling() {
14901        let mut app = test_app();
14902        app.current_service = Service::IamRoles;
14903        app.service_selected = true;
14904        app.iam_state.current_role = Some("test-role".to_string());
14905        app.iam_state.role_tab = RoleTab::Tags;
14906        app.mode = Mode::ColumnSelector;
14907        app.column_selector_index = 0;
14908
14909        // NextPreferences from column section -> page size
14910        app.handle_action(Action::NextPreferences);
14911        assert_eq!(app.column_selector_index, 4);
14912
14913        // NextPreferences from page size -> column section
14914        app.handle_action(Action::NextPreferences);
14915        assert_eq!(app.column_selector_index, 0);
14916
14917        // PrevPreferences from column section -> page size
14918        app.handle_action(Action::PrevPreferences);
14919        assert_eq!(app.column_selector_index, 4);
14920
14921        // PrevPreferences from page size -> column section
14922        app.handle_action(Action::PrevPreferences);
14923        assert_eq!(app.column_selector_index, 0);
14924    }
14925
14926    #[test]
14927    fn test_cfn_outputs_expand_collapse() {
14928        let mut app = test_app();
14929        app.current_service = Service::CloudFormationStacks;
14930        app.service_selected = true;
14931        app.cfn_state.current_stack = Some("test-stack".to_string());
14932        app.cfn_state.detail_tab = CfnDetailTab::Outputs;
14933        app.cfn_state.outputs.items = vec![rusticity_core::cfn::StackOutput {
14934            key: "Output1".to_string(),
14935            value: "Value1".to_string(),
14936            description: "Description1".to_string(),
14937            export_name: "Export1".to_string(),
14938        }];
14939        app.cfn_state.outputs.reset();
14940
14941        assert_eq!(app.cfn_state.outputs.expanded_item, None);
14942
14943        // Right arrow expands
14944        app.handle_action(Action::NextPane);
14945        assert_eq!(app.cfn_state.outputs.expanded_item, Some(0));
14946
14947        // Left arrow collapses
14948        app.handle_action(Action::PrevPane);
14949        assert_eq!(app.cfn_state.outputs.expanded_item, None);
14950    }
14951
14952    #[test]
14953    fn test_cfn_outputs_filter_resets_selection() {
14954        let mut app = test_app();
14955        app.current_service = Service::CloudFormationStacks;
14956        app.service_selected = true;
14957        app.cfn_state.current_stack = Some("test-stack".to_string());
14958        app.cfn_state.detail_tab = CfnDetailTab::Outputs;
14959        app.cfn_state.outputs.items = vec![
14960            rusticity_core::cfn::StackOutput {
14961                key: "ApiUrl".to_string(),
14962                value: "https://api.example.com".to_string(),
14963                description: "API endpoint".to_string(),
14964                export_name: "MyApiUrl".to_string(),
14965            },
14966            rusticity_core::cfn::StackOutput {
14967                key: "BucketName".to_string(),
14968                value: "my-bucket".to_string(),
14969                description: "S3 bucket".to_string(),
14970                export_name: "MyBucket".to_string(),
14971            },
14972        ];
14973        app.cfn_state.outputs.reset();
14974        app.cfn_state.outputs.selected = 1;
14975
14976        // Start filter mode
14977        app.handle_action(Action::StartFilter);
14978        assert_eq!(app.mode, Mode::FilterInput);
14979
14980        // Type a character - should reset selection
14981        app.handle_action(Action::FilterInput('A'));
14982        assert_eq!(app.cfn_state.outputs.selected, 0);
14983        assert_eq!(app.cfn_state.outputs.filter, "A");
14984
14985        // Type more
14986        app.cfn_state.outputs.selected = 1;
14987        app.handle_action(Action::FilterInput('p'));
14988        assert_eq!(app.cfn_state.outputs.selected, 0);
14989
14990        // Backspace should also reset selection
14991        app.cfn_state.outputs.selected = 1;
14992        app.handle_action(Action::FilterBackspace);
14993        assert_eq!(app.cfn_state.outputs.selected, 0);
14994    }
14995
14996    #[test]
14997    fn test_ec2_service_in_picker() {
14998        let app = test_app();
14999        assert!(app.service_picker.services.contains(&"EC2 › Instances"));
15000    }
15001
15002    #[test]
15003    fn test_ec2_state_filter_cycles() {
15004        let mut app = test_app();
15005        app.current_service = Service::Ec2Instances;
15006        app.service_selected = true;
15007        app.mode = Mode::FilterInput;
15008        app.ec2_state.input_focus = EC2_STATE_FILTER;
15009
15010        let initial = app.ec2_state.state_filter;
15011        assert_eq!(initial, Ec2StateFilter::AllStates);
15012
15013        // Cycle through filters using ToggleFilterCheckbox
15014        app.handle_action(Action::ToggleFilterCheckbox);
15015        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Running);
15016
15017        app.handle_action(Action::ToggleFilterCheckbox);
15018        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Stopped);
15019
15020        app.handle_action(Action::ToggleFilterCheckbox);
15021        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Terminated);
15022
15023        app.handle_action(Action::ToggleFilterCheckbox);
15024        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Pending);
15025
15026        app.handle_action(Action::ToggleFilterCheckbox);
15027        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::ShuttingDown);
15028
15029        app.handle_action(Action::ToggleFilterCheckbox);
15030        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Stopping);
15031
15032        app.handle_action(Action::ToggleFilterCheckbox);
15033        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::AllStates);
15034    }
15035
15036    #[test]
15037    fn test_ec2_filter_resets_table() {
15038        let mut app = test_app();
15039        app.current_service = Service::Ec2Instances;
15040        app.service_selected = true;
15041        app.mode = Mode::FilterInput;
15042        app.ec2_state.input_focus = EC2_STATE_FILTER;
15043        app.ec2_state.table.selected = 5;
15044
15045        app.handle_action(Action::ToggleFilterCheckbox);
15046        assert_eq!(app.ec2_state.table.selected, 0);
15047    }
15048
15049    #[test]
15050    fn test_ec2_columns_visible() {
15051        let app = test_app();
15052        assert_eq!(app.ec2_visible_column_ids.len(), 16); // Default visible columns
15053        assert_eq!(app.ec2_column_ids.len(), 52); // Total available columns
15054    }
15055
15056    #[test]
15057    fn test_ec2_breadcrumbs() {
15058        let mut app = test_app();
15059        app.current_service = Service::Ec2Instances;
15060        app.service_selected = true;
15061        let breadcrumb = app.breadcrumbs();
15062        assert_eq!(breadcrumb, "EC2 > Instances");
15063    }
15064
15065    #[test]
15066    fn test_ec2_console_url() {
15067        let mut app = test_app();
15068        app.current_service = Service::Ec2Instances;
15069        app.service_selected = true;
15070        let url = app.get_console_url();
15071        assert!(url.contains("ec2"));
15072        assert!(url.contains("Instances"));
15073    }
15074
15075    #[test]
15076    fn test_ec2_filter_handling() {
15077        let mut app = test_app();
15078        app.current_service = Service::Ec2Instances;
15079        app.service_selected = true;
15080        app.mode = Mode::FilterInput;
15081
15082        app.handle_action(Action::FilterInput('t'));
15083        app.handle_action(Action::FilterInput('e'));
15084        app.handle_action(Action::FilterInput('s'));
15085        app.handle_action(Action::FilterInput('t'));
15086
15087        assert_eq!(app.ec2_state.table.filter, "test");
15088
15089        app.handle_action(Action::FilterBackspace);
15090        assert_eq!(app.ec2_state.table.filter, "tes");
15091    }
15092
15093    #[test]
15094    fn test_column_selector_page_down_ec2() {
15095        let mut app = test_app();
15096        app.current_service = Service::Ec2Instances;
15097        app.service_selected = true;
15098        app.mode = Mode::ColumnSelector;
15099        app.column_selector_index = 0;
15100
15101        app.handle_action(Action::PageDown);
15102        assert_eq!(app.column_selector_index, 10);
15103
15104        app.handle_action(Action::PageDown);
15105        assert_eq!(app.column_selector_index, 20);
15106    }
15107
15108    #[test]
15109    fn test_column_selector_page_up_ec2() {
15110        let mut app = test_app();
15111        app.current_service = Service::Ec2Instances;
15112        app.service_selected = true;
15113        app.mode = Mode::ColumnSelector;
15114        app.column_selector_index = 30;
15115
15116        app.handle_action(Action::PageUp);
15117        assert_eq!(app.column_selector_index, 20);
15118
15119        app.handle_action(Action::PageUp);
15120        assert_eq!(app.column_selector_index, 10);
15121    }
15122
15123    #[test]
15124    fn test_ec2_state_filter_dropdown_focus() {
15125        let mut app = test_app();
15126        app.current_service = Service::Ec2Instances;
15127        app.service_selected = true;
15128        app.mode = Mode::FilterInput;
15129
15130        // Tab to state filter
15131        app.handle_action(Action::NextFilterFocus);
15132        assert_eq!(app.ec2_state.input_focus, EC2_STATE_FILTER);
15133
15134        // Dropdown should show when focused (tested in render)
15135        // Verify we can cycle the filter
15136        app.handle_action(Action::ToggleFilterCheckbox);
15137        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Running);
15138    }
15139
15140    #[test]
15141    fn test_column_selector_ctrl_d_scrolling() {
15142        let mut app = test_app();
15143        app.current_service = Service::LambdaFunctions;
15144        app.mode = Mode::ColumnSelector;
15145        app.column_selector_index = 0;
15146
15147        // PageDown from 0 by 10 lands on blank row (10), skips to 11
15148        app.handle_action(Action::PageDown);
15149        assert_eq!(app.column_selector_index, 11);
15150
15151        // Second PageDown should be capped at max
15152        let max = app.get_column_selector_max();
15153        app.handle_action(Action::PageDown);
15154        assert_eq!(app.column_selector_index, max);
15155    }
15156
15157    #[test]
15158    fn test_column_selector_ctrl_u_scrolling() {
15159        let mut app = test_app();
15160        app.current_service = Service::CloudFormationStacks;
15161        app.mode = Mode::ColumnSelector;
15162        app.column_selector_index = 25;
15163
15164        app.handle_action(Action::PageUp);
15165        assert_eq!(app.column_selector_index, 15);
15166
15167        app.handle_action(Action::PageUp);
15168        assert_eq!(app.column_selector_index, 5);
15169    }
15170
15171    #[test]
15172    fn test_prev_preferences_lambda() {
15173        let mut app = test_app();
15174        app.current_service = Service::LambdaFunctions;
15175        app.mode = Mode::ColumnSelector;
15176        let page_size_idx = app.lambda_state.function_column_ids.len() + 2;
15177        app.column_selector_index = page_size_idx;
15178
15179        app.handle_action(Action::PrevPreferences);
15180        assert_eq!(app.column_selector_index, 0);
15181
15182        app.handle_action(Action::PrevPreferences);
15183        assert_eq!(app.column_selector_index, page_size_idx);
15184    }
15185
15186    #[test]
15187    fn test_prev_preferences_cloudformation() {
15188        let mut app = test_app();
15189        app.current_service = Service::CloudFormationStacks;
15190        app.mode = Mode::ColumnSelector;
15191        let page_size_idx = app.cfn_column_ids.len() + 2;
15192        app.column_selector_index = page_size_idx;
15193
15194        app.handle_action(Action::PrevPreferences);
15195        assert_eq!(app.column_selector_index, 0);
15196
15197        app.handle_action(Action::PrevPreferences);
15198        assert_eq!(app.column_selector_index, page_size_idx);
15199    }
15200
15201    #[test]
15202    fn test_prev_preferences_alarms() {
15203        let mut app = test_app();
15204        app.current_service = Service::CloudWatchAlarms;
15205        app.mode = Mode::ColumnSelector;
15206        app.column_selector_index = 28; // WrapLines
15207
15208        app.handle_action(Action::PrevPreferences);
15209        assert_eq!(app.column_selector_index, 22); // PageSize
15210
15211        app.handle_action(Action::PrevPreferences);
15212        assert_eq!(app.column_selector_index, 18); // ViewAs
15213
15214        app.handle_action(Action::PrevPreferences);
15215        assert_eq!(app.column_selector_index, 0); // Columns
15216
15217        app.handle_action(Action::PrevPreferences);
15218        assert_eq!(app.column_selector_index, 28); // Wrap to WrapLines
15219    }
15220
15221    #[test]
15222    fn test_ec2_page_size_in_preferences() {
15223        let mut app = test_app();
15224        app.current_service = Service::Ec2Instances;
15225        app.mode = Mode::ColumnSelector;
15226        app.ec2_state.table.page_size = PageSize::Fifty;
15227
15228        // Navigate to page size section
15229        let page_size_idx = app.ec2_column_ids.len() + 3; // First page size option (10)
15230        app.column_selector_index = page_size_idx;
15231        app.handle_action(Action::ToggleColumn);
15232
15233        assert_eq!(app.ec2_state.table.page_size, PageSize::Ten);
15234    }
15235
15236    #[test]
15237    fn test_ec2_next_preferences_with_page_size() {
15238        let mut app = test_app();
15239        app.current_service = Service::Ec2Instances;
15240        app.mode = Mode::ColumnSelector;
15241        app.column_selector_index = 0;
15242
15243        let page_size_idx = app.ec2_column_ids.len() + 2;
15244        app.handle_action(Action::NextPreferences);
15245        assert_eq!(app.column_selector_index, page_size_idx);
15246
15247        app.handle_action(Action::NextPreferences);
15248        assert_eq!(app.column_selector_index, 0);
15249    }
15250
15251    #[test]
15252    fn test_ec2_dropdown_next_item() {
15253        let mut app = test_app();
15254        app.current_service = Service::Ec2Instances;
15255        app.mode = Mode::FilterInput;
15256        app.ec2_state.input_focus = EC2_STATE_FILTER;
15257        app.ec2_state.state_filter = Ec2StateFilter::AllStates;
15258
15259        app.handle_action(Action::NextItem);
15260        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Running);
15261
15262        app.handle_action(Action::NextItem);
15263        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Stopped);
15264    }
15265
15266    #[test]
15267    fn test_ec2_dropdown_prev_item() {
15268        let mut app = test_app();
15269        app.current_service = Service::Ec2Instances;
15270        app.mode = Mode::FilterInput;
15271        app.ec2_state.input_focus = EC2_STATE_FILTER;
15272        app.ec2_state.state_filter = Ec2StateFilter::Stopped;
15273
15274        app.handle_action(Action::PrevItem);
15275        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Running);
15276
15277        app.handle_action(Action::PrevItem);
15278        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::AllStates);
15279    }
15280
15281    #[test]
15282    fn test_ec2_dropdown_cycles_with_arrows() {
15283        let mut app = test_app();
15284        app.current_service = Service::Ec2Instances;
15285        app.mode = Mode::FilterInput;
15286        app.ec2_state.input_focus = EC2_STATE_FILTER;
15287        app.ec2_state.state_filter = Ec2StateFilter::Stopping;
15288
15289        // Next wraps to AllStates
15290        app.handle_action(Action::NextItem);
15291        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::AllStates);
15292
15293        // Prev wraps to Stopping
15294        app.handle_action(Action::PrevItem);
15295        assert_eq!(app.ec2_state.state_filter, Ec2StateFilter::Stopping);
15296    }
15297
15298    #[test]
15299    fn test_collapse_row_ec2_instances() {
15300        let mut app = test_app();
15301        app.current_service = Service::Ec2Instances;
15302        app.ec2_state.table.expanded_item = Some(0);
15303
15304        app.handle_action(Action::CollapseRow);
15305        assert_eq!(app.ec2_state.table.expanded_item, None);
15306    }
15307
15308    #[test]
15309    fn test_collapse_row_ec2_tags() {
15310        let mut app = test_app();
15311        app.current_service = Service::Ec2Instances;
15312        app.ec2_state.current_instance = Some("i-123".to_string());
15313        app.ec2_state.detail_tab = Ec2DetailTab::Tags;
15314        app.ec2_state.tags.expanded_item = Some(1);
15315
15316        app.handle_action(Action::CollapseRow);
15317        assert_eq!(app.ec2_state.tags.expanded_item, None);
15318    }
15319
15320    #[test]
15321    fn test_collapse_row_cloudwatch_log_groups() {
15322        let mut app = test_app();
15323        app.current_service = Service::CloudWatchLogGroups;
15324        app.log_groups_state.log_groups.expanded_item = Some(2);
15325
15326        app.handle_action(Action::CollapseRow);
15327        assert_eq!(app.log_groups_state.log_groups.expanded_item, None);
15328    }
15329
15330    #[test]
15331    fn test_collapse_row_cloudwatch_alarms() {
15332        let mut app = test_app();
15333        app.current_service = Service::CloudWatchAlarms;
15334        app.alarms_state.table.expanded_item = Some(0);
15335
15336        app.handle_action(Action::CollapseRow);
15337        assert_eq!(app.alarms_state.table.expanded_item, None);
15338    }
15339
15340    #[test]
15341    fn test_collapse_row_lambda_functions() {
15342        let mut app = test_app();
15343        app.current_service = Service::LambdaFunctions;
15344        app.lambda_state.table.expanded_item = Some(1);
15345
15346        app.handle_action(Action::CollapseRow);
15347        assert_eq!(app.lambda_state.table.expanded_item, None);
15348    }
15349
15350    #[test]
15351    fn test_collapse_row_cfn_stacks() {
15352        let mut app = test_app();
15353        app.current_service = Service::CloudFormationStacks;
15354        app.cfn_state.table.expanded_item = Some(0);
15355
15356        app.handle_action(Action::CollapseRow);
15357        assert_eq!(app.cfn_state.table.expanded_item, None);
15358    }
15359
15360    #[test]
15361    fn test_collapse_row_cfn_resources() {
15362        let mut app = test_app();
15363        app.current_service = Service::CloudFormationStacks;
15364        app.cfn_state.current_stack = Some("test-stack".to_string());
15365        app.cfn_state.detail_tab = crate::ui::cfn::DetailTab::Resources;
15366        app.cfn_state.resources.expanded_item = Some(2);
15367
15368        app.handle_action(Action::CollapseRow);
15369        assert_eq!(app.cfn_state.resources.expanded_item, None);
15370    }
15371
15372    #[test]
15373    fn test_collapse_row_iam_users() {
15374        let mut app = test_app();
15375        app.current_service = Service::IamUsers;
15376        app.iam_state.users.expanded_item = Some(1);
15377
15378        app.handle_action(Action::CollapseRow);
15379        assert_eq!(app.iam_state.users.expanded_item, None);
15380    }
15381
15382    #[test]
15383    fn test_collapse_row_does_nothing_when_not_expanded() {
15384        let mut app = test_app();
15385        app.current_service = Service::Ec2Instances;
15386        app.ec2_state.table.expanded_item = None;
15387
15388        app.handle_action(Action::CollapseRow);
15389        assert_eq!(app.ec2_state.table.expanded_item, None);
15390    }
15391
15392    #[test]
15393    fn test_s3_collapse_expanded_folder_moves_to_parent() {
15394        let mut app = test_app();
15395        app.current_service = Service::S3Buckets;
15396        app.service_selected = true;
15397        app.mode = Mode::Normal;
15398
15399        // Add bucket with folder
15400        app.s3_state.buckets.items = vec![S3Bucket {
15401            name: "bucket1".to_string(),
15402            region: "us-east-1".to_string(),
15403            creation_date: "2024-01-01T00:00:00Z".to_string(),
15404        }];
15405
15406        // Expand bucket
15407        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
15408        app.s3_state.bucket_preview.insert(
15409            "bucket1".to_string(),
15410            vec![S3Object {
15411                key: "folder1/".to_string(),
15412                size: 0,
15413                last_modified: "2024-01-01T00:00:00Z".to_string(),
15414                is_prefix: true,
15415                storage_class: String::new(),
15416            }],
15417        );
15418
15419        // Expand folder
15420        app.s3_state
15421            .expanded_prefixes
15422            .insert("folder1/".to_string());
15423        app.s3_state.prefix_preview.insert(
15424            "folder1/".to_string(),
15425            vec![S3Object {
15426                key: "folder1/file.txt".to_string(),
15427                size: 0,
15428                last_modified: "2024-01-01T00:00:00Z".to_string(),
15429                is_prefix: false,
15430                storage_class: String::new(),
15431            }],
15432        );
15433
15434        // Select the expanded folder (row 1)
15435        app.s3_state.selected_row = 1;
15436
15437        // Press Left to collapse
15438        app.handle_action(Action::PrevPane);
15439
15440        // Folder should be collapsed
15441        assert!(!app.s3_state.expanded_prefixes.contains("folder1/"));
15442        // Selection should move to parent (bucket at row 0)
15443        assert_eq!(app.s3_state.selected_row, 0);
15444    }
15445
15446    #[test]
15447    fn test_s3_collapse_hierarchy_level_by_level() {
15448        let mut app = test_app();
15449        app.current_service = Service::S3Buckets;
15450        app.service_selected = true;
15451        app.mode = Mode::Normal;
15452
15453        // Add bucket with 3-level hierarchy
15454        app.s3_state.buckets.items = vec![S3Bucket {
15455            name: "bucket1".to_string(),
15456            region: "us-east-1".to_string(),
15457            creation_date: "2024-01-01T00:00:00Z".to_string(),
15458        }];
15459
15460        // Level 1: bucket -> level1/
15461        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
15462        app.s3_state.bucket_preview.insert(
15463            "bucket1".to_string(),
15464            vec![S3Object {
15465                key: "level1/".to_string(),
15466                size: 0,
15467                last_modified: "2024-01-01T00:00:00Z".to_string(),
15468                is_prefix: true,
15469                storage_class: String::new(),
15470            }],
15471        );
15472
15473        // Level 2: level1/ -> level2/
15474        app.s3_state.expanded_prefixes.insert("level1/".to_string());
15475        app.s3_state.prefix_preview.insert(
15476            "level1/".to_string(),
15477            vec![S3Object {
15478                key: "level1/level2/".to_string(),
15479                size: 0,
15480                last_modified: "2024-01-01T00:00:00Z".to_string(),
15481                is_prefix: true,
15482                storage_class: String::new(),
15483            }],
15484        );
15485
15486        // Level 3: level2/ -> file
15487        app.s3_state
15488            .expanded_prefixes
15489            .insert("level1/level2/".to_string());
15490        app.s3_state.prefix_preview.insert(
15491            "level1/level2/".to_string(),
15492            vec![S3Object {
15493                key: "level1/level2/file.txt".to_string(),
15494                size: 100,
15495                last_modified: "2024-01-01T00:00:00Z".to_string(),
15496                is_prefix: false,
15497                storage_class: String::new(),
15498            }],
15499        );
15500
15501        // Select deepest level (row 3: file)
15502        app.s3_state.selected_row = 3;
15503
15504        // First Left: move to parent (level2/ at row 2)
15505        app.handle_action(Action::PrevPane);
15506        assert_eq!(app.s3_state.selected_row, 2);
15507
15508        // Second Left: collapse level2/ and move to parent (level1/ at row 1)
15509        app.handle_action(Action::PrevPane);
15510        assert!(!app.s3_state.expanded_prefixes.contains("level1/level2/"));
15511        assert_eq!(app.s3_state.selected_row, 1);
15512
15513        // Third Left: collapse level1/ and move to parent (bucket at row 0)
15514        app.handle_action(Action::PrevPane);
15515        assert!(!app.s3_state.expanded_prefixes.contains("level1/"));
15516        assert_eq!(app.s3_state.selected_row, 0);
15517
15518        // Fourth Left: collapse bucket (stays at row 0)
15519        app.handle_action(Action::PrevPane);
15520        assert!(!app.s3_state.expanded_prefixes.contains("bucket1"));
15521        assert_eq!(app.s3_state.selected_row, 0);
15522    }
15523
15524    #[test]
15525    fn test_ec2_instance_detail_tabs_no_preferences() {
15526        let mut app = test_app();
15527        app.current_service = Service::Ec2Instances;
15528        app.service_selected = true;
15529        app.ec2_state.table.expanded_item = Some(0);
15530        app.mode = Mode::Normal;
15531
15532        // Details tab should NOT allow preferences
15533        app.ec2_state.detail_tab = Ec2DetailTab::Details;
15534        app.handle_action(Action::OpenColumnSelector);
15535        assert_eq!(app.mode, Mode::Normal);
15536
15537        // StatusAndAlarms tab should NOT allow preferences
15538        app.ec2_state.detail_tab = Ec2DetailTab::StatusAndAlarms;
15539        app.handle_action(Action::OpenColumnSelector);
15540        assert_eq!(app.mode, Mode::Normal);
15541
15542        // Monitoring tab should NOT allow preferences
15543        app.ec2_state.detail_tab = Ec2DetailTab::Monitoring;
15544        app.handle_action(Action::OpenColumnSelector);
15545        assert_eq!(app.mode, Mode::Normal);
15546
15547        // Security tab should NOT allow preferences
15548        app.ec2_state.detail_tab = Ec2DetailTab::Security;
15549        app.handle_action(Action::OpenColumnSelector);
15550        assert_eq!(app.mode, Mode::Normal);
15551
15552        // Networking tab should NOT allow preferences
15553        app.ec2_state.detail_tab = Ec2DetailTab::Networking;
15554        app.handle_action(Action::OpenColumnSelector);
15555        assert_eq!(app.mode, Mode::Normal);
15556
15557        // Storage tab should NOT allow preferences
15558        app.ec2_state.detail_tab = Ec2DetailTab::Storage;
15559        app.handle_action(Action::OpenColumnSelector);
15560        assert_eq!(app.mode, Mode::Normal);
15561
15562        // Tags tab SHOULD allow preferences
15563        app.ec2_state.detail_tab = Ec2DetailTab::Tags;
15564        app.handle_action(Action::OpenColumnSelector);
15565        assert_eq!(app.mode, Mode::ColumnSelector);
15566    }
15567
15568    #[test]
15569    fn test_ec2_monitoring_ctrl_u_scrolls_up() {
15570        // Regression: Ctrl+D (PageDown) scrolls monitoring graphs down but
15571        // Ctrl+U (PageUp) did NOT scroll up — it called page_up_normal which
15572        // only pages the instance table, not the monitoring scroll.
15573        let mut app = test_app();
15574        app.current_service = Service::Ec2Instances;
15575        app.service_selected = true;
15576        app.mode = Mode::Normal;
15577        app.ec2_state.current_instance = Some("i-abc".to_string());
15578        app.ec2_state.detail_tab = Ec2DetailTab::Monitoring;
15579
15580        // Set scroll to non-zero
15581        app.ec2_state.set_monitoring_scroll(3);
15582        assert_eq!(app.ec2_state.monitoring_scroll(), 3);
15583
15584        // Ctrl+U (PageUp) must scroll monitoring up
15585        app.handle_action(Action::PageUp);
15586        assert_eq!(
15587            app.ec2_state.monitoring_scroll(),
15588            2,
15589            "Ctrl+U must scroll monitoring up by 1"
15590        );
15591
15592        // Ctrl+D (PageDown) scrolls down — verify symmetry
15593        app.handle_action(Action::PageDown);
15594        assert_eq!(
15595            app.ec2_state.monitoring_scroll(),
15596            3,
15597            "Ctrl+D must scroll monitoring down by 1"
15598        );
15599    }
15600
15601    #[test]
15602    fn test_ec2_instance_always_shows_when_state_filter_is_all_states() {
15603        // Regression: an instance must appear in the table when state_filter is AllStates,
15604        // regardless of instance state or whether name/private_ip are empty.
15605        use crate::app::Ec2Instance;
15606        use crate::ui::ec2::{filtered_ec2_instances, StateFilter};
15607        let mut app = test_app();
15608        app.current_service = Service::Ec2Instances;
15609        app.service_selected = true;
15610        app.ec2_state.table.filter.clear();
15611        app.ec2_state.state_filter = StateFilter::AllStates;
15612
15613        // Instance with only id set (no name, no private IP — like a real unnamed instance)
15614        app.ec2_state.table.items = vec![Ec2Instance {
15615            instance_id: "i-05f181d4aaabadf5a".to_string(),
15616            state: "running".to_string(),
15617            ..Default::default()
15618        }];
15619
15620        let filtered = filtered_ec2_instances(&app);
15621        assert_eq!(
15622            filtered.len(),
15623            1,
15624            "Instance must appear with AllStates filter and no text filter"
15625        );
15626        assert_eq!(filtered[0].instance_id, "i-05f181d4aaabadf5a");
15627    }
15628
15629    #[test]
15630    fn test_ec2_instance_private_ip_populated_from_load() {
15631        // Regression: load_ec2_instances was setting private_ip_address = String::new()
15632        // which means the render shows no private IP and filter by private IP never works.
15633        // Verify the field is mapped from the loaded instance data.
15634        use crate::app::Ec2Instance;
15635        let instance = Ec2Instance {
15636            instance_id: "i-abc".to_string(),
15637            name: "my-server".to_string(),
15638            state: "running".to_string(),
15639            private_dns_name: "ip-10-0-1-5.ec2.internal".to_string(),
15640            private_ip_address: "10.0.1.5".to_string(),
15641            security_group_ids: "sg-abc123".to_string(),
15642            ..Default::default()
15643        };
15644        // These fields must be set (not empty) for display and filter to work
15645        assert!(
15646            !instance.private_ip_address.is_empty(),
15647            "private_ip_address must be populated"
15648        );
15649        assert!(
15650            !instance.private_dns_name.is_empty(),
15651            "private_dns_name must be populated"
15652        );
15653    }
15654
15655    #[test]
15656    fn test_log_streams_filter_only_updates_when_focused() {
15657        let mut app = test_app();
15658        app.current_service = Service::CloudWatchLogGroups;
15659        app.service_selected = true;
15660        app.view_mode = ViewMode::Detail;
15661        app.mode = Mode::FilterInput;
15662        app.log_groups_state.stream_filter = "test".to_string();
15663
15664        // When filter is focused, typing should update filter
15665        app.log_groups_state.input_focus = InputFocus::Filter;
15666        app.handle_action(Action::FilterInput('x'));
15667        assert_eq!(app.log_groups_state.stream_filter, "testx");
15668
15669        // When pagination is focused, typing should NOT update filter
15670        app.log_groups_state.input_focus = InputFocus::Pagination;
15671        app.handle_action(Action::FilterInput('y'));
15672        assert_eq!(app.log_groups_state.stream_filter, "testx"); // unchanged
15673    }
15674
15675    #[test]
15676    fn test_log_streams_backspace_only_updates_when_focused() {
15677        let mut app = test_app();
15678        app.current_service = Service::CloudWatchLogGroups;
15679        app.service_selected = true;
15680        app.view_mode = ViewMode::Detail;
15681        app.mode = Mode::FilterInput;
15682        app.log_groups_state.stream_filter = "test".to_string();
15683
15684        // When filter is focused, backspace should update filter
15685        app.log_groups_state.input_focus = InputFocus::Filter;
15686        app.handle_action(Action::FilterBackspace);
15687        assert_eq!(app.log_groups_state.stream_filter, "tes");
15688
15689        // When pagination is focused, backspace should NOT update filter
15690        app.log_groups_state.input_focus = InputFocus::Pagination;
15691        app.handle_action(Action::FilterBackspace);
15692        assert_eq!(app.log_groups_state.stream_filter, "tes"); // unchanged
15693    }
15694
15695    #[test]
15696    fn test_log_groups_filter_only_updates_when_focused() {
15697        let mut app = test_app();
15698        app.current_service = Service::CloudWatchLogGroups;
15699        app.service_selected = true;
15700        app.view_mode = ViewMode::List;
15701        app.mode = Mode::FilterInput;
15702        app.log_groups_state.log_groups.filter = "test".to_string();
15703
15704        // When filter is focused, typing should update filter
15705        app.log_groups_state.input_focus = InputFocus::Filter;
15706        app.handle_action(Action::FilterInput('x'));
15707        assert_eq!(app.log_groups_state.log_groups.filter, "testx");
15708
15709        // When pagination is focused, typing should NOT update filter
15710        app.log_groups_state.input_focus = InputFocus::Pagination;
15711        app.handle_action(Action::FilterInput('y'));
15712        assert_eq!(app.log_groups_state.log_groups.filter, "testx"); // unchanged
15713    }
15714
15715    #[test]
15716    fn test_s3_bucket_collapse_nested_prefix_jumps_to_parent() {
15717        use S3Bucket;
15718        use S3Object;
15719
15720        let mut app = test_app();
15721        app.current_service = Service::S3Buckets;
15722        app.service_selected = true;
15723
15724        // Create bucket with nested prefixes
15725        app.s3_state.buckets.items = vec![S3Bucket {
15726            name: "test-bucket".to_string(),
15727            region: "us-east-1".to_string(),
15728            creation_date: String::new(),
15729        }];
15730
15731        // Expand bucket with folder1/
15732        app.s3_state
15733            .expanded_prefixes
15734            .insert("test-bucket".to_string());
15735        app.s3_state.bucket_preview.insert(
15736            "test-bucket".to_string(),
15737            vec![S3Object {
15738                key: "folder1/".to_string(),
15739                is_prefix: true,
15740                size: 0,
15741                last_modified: String::new(),
15742                storage_class: String::new(),
15743            }],
15744        );
15745
15746        // Expand folder1/ with folder2/
15747        app.s3_state
15748            .expanded_prefixes
15749            .insert("folder1/".to_string());
15750        app.s3_state.prefix_preview.insert(
15751            "folder1/".to_string(),
15752            vec![S3Object {
15753                key: "folder1/folder2/".to_string(),
15754                is_prefix: true,
15755                size: 0,
15756                last_modified: String::new(),
15757                storage_class: String::new(),
15758            }],
15759        );
15760
15761        // Select folder2/ (row 0: bucket, row 1: folder1, row 2: folder2)
15762        app.s3_state.selected_row = 2;
15763
15764        // Press left arrow - should collapse folder2 and jump to folder1
15765        app.handle_action(Action::CollapseRow);
15766
15767        // folder2 should be collapsed
15768        assert!(!app.s3_state.expanded_prefixes.contains("folder1/folder2/"));
15769        // Selection should move to folder1 (row 1)
15770        assert_eq!(app.s3_state.selected_row, 1);
15771    }
15772
15773    #[test]
15774    fn test_s3_bucket_collapse_expanded_folder_moves_to_parent() {
15775        use S3Bucket;
15776        use S3Object;
15777
15778        let mut app = test_app();
15779        app.current_service = Service::S3Buckets;
15780        app.service_selected = true;
15781
15782        // Create bucket with folder
15783        app.s3_state.buckets.items = vec![S3Bucket {
15784            name: "test-bucket".to_string(),
15785            region: "us-east-1".to_string(),
15786            creation_date: String::new(),
15787        }];
15788
15789        // Expand bucket with folder1/
15790        app.s3_state
15791            .expanded_prefixes
15792            .insert("test-bucket".to_string());
15793        app.s3_state.bucket_preview.insert(
15794            "test-bucket".to_string(),
15795            vec![S3Object {
15796                key: "folder1/".to_string(),
15797                is_prefix: true,
15798                size: 0,
15799                last_modified: String::new(),
15800                storage_class: String::new(),
15801            }],
15802        );
15803
15804        // Expand folder1/
15805        app.s3_state
15806            .expanded_prefixes
15807            .insert("folder1/".to_string());
15808        app.s3_state.prefix_preview.insert(
15809            "folder1/".to_string(),
15810            vec![S3Object {
15811                key: "folder1/file.txt".to_string(),
15812                is_prefix: false,
15813                size: 100,
15814                last_modified: String::new(),
15815                storage_class: String::new(),
15816            }],
15817        );
15818
15819        // Select folder1/ (row 0: bucket, row 1: folder1)
15820        app.s3_state.selected_row = 1;
15821
15822        // Press left arrow - should collapse folder1 and jump to bucket
15823        app.handle_action(Action::CollapseRow);
15824
15825        // folder1 should be collapsed
15826        assert!(!app.s3_state.expanded_prefixes.contains("folder1/"));
15827        // Selection should move to bucket (row 0)
15828        assert_eq!(app.s3_state.selected_row, 0);
15829    }
15830
15831    #[test]
15832    fn test_log_streams_pagination_limits_table_content() {
15833        let mut app = test_app();
15834        app.current_service = Service::CloudWatchLogGroups;
15835        app.service_selected = true;
15836        app.view_mode = ViewMode::Detail;
15837
15838        // Create 50 log streams
15839        app.log_groups_state.log_streams = (0..50)
15840            .map(|i| rusticity_core::LogStream {
15841                name: format!("stream-{}", i),
15842                creation_time: None,
15843                last_event_time: None,
15844            })
15845            .collect();
15846
15847        // Set page size to 10
15848        app.log_groups_state.stream_page_size = 10;
15849        app.log_groups_state.stream_current_page = 0;
15850
15851        // First page should show streams 0-9
15852        // (This is tested by rendering, but we can verify pagination logic)
15853        assert_eq!(app.log_groups_state.stream_page_size, 10);
15854        assert_eq!(app.log_groups_state.stream_current_page, 0);
15855
15856        // Navigate to page 2
15857        app.log_groups_state.stream_current_page = 1;
15858        assert_eq!(app.log_groups_state.stream_current_page, 1);
15859    }
15860
15861    #[test]
15862    fn test_log_streams_page_size_change_resets_page() {
15863        let mut app = test_app();
15864        app.current_service = Service::CloudWatchLogGroups;
15865        app.service_selected = true;
15866        app.view_mode = ViewMode::Detail;
15867        app.mode = Mode::ColumnSelector;
15868
15869        app.log_groups_state.stream_page_size = 10;
15870        app.log_groups_state.stream_current_page = 3;
15871
15872        // Change page size - should reset to page 0
15873        app.column_selector_index = app.cw_log_stream_column_ids.len() + 4; // 25 items
15874        app.handle_action(Action::ToggleColumn);
15875
15876        assert_eq!(app.log_groups_state.stream_page_size, 25);
15877        assert_eq!(app.log_groups_state.stream_current_page, 0);
15878    }
15879
15880    #[test]
15881    fn test_s3_objects_expanded_rows_stay_visible() {
15882        use S3Object;
15883
15884        let mut app = test_app();
15885        app.current_service = Service::S3Buckets;
15886        app.service_selected = true;
15887        app.mode = Mode::Normal;
15888        app.s3_state.current_bucket = Some("test-bucket".to_string());
15889
15890        // Create a folder with many nested items
15891        app.s3_state.objects = vec![S3Object {
15892            key: "folder1/".to_string(),
15893            is_prefix: true,
15894            size: 0,
15895            last_modified: String::new(),
15896            storage_class: String::new(),
15897        }];
15898
15899        // Expand folder1 with 20 files
15900        app.s3_state
15901            .expanded_prefixes
15902            .insert("folder1/".to_string());
15903        app.s3_state.prefix_preview.insert(
15904            "folder1/".to_string(),
15905            (0..20)
15906                .map(|i| S3Object {
15907                    key: format!("folder1/file{}.txt", i),
15908                    is_prefix: false,
15909                    size: 100,
15910                    last_modified: String::new(),
15911                    storage_class: String::new(),
15912                })
15913                .collect(),
15914        );
15915
15916        // Set viewport to show 10 rows
15917        app.s3_state.object_visible_rows.set(10);
15918        app.s3_state.object_scroll_offset = 0;
15919        app.s3_state.selected_object = 0; // folder1
15920
15921        // Navigate down through all items
15922        for i in 1..=20 {
15923            app.handle_action(Action::NextItem);
15924            assert_eq!(app.s3_state.selected_object, i);
15925
15926            // Check that selection is within visible range
15927            let visible_start = app.s3_state.object_scroll_offset;
15928            let visible_end = visible_start + app.s3_state.object_visible_rows.get();
15929            assert!(
15930                app.s3_state.selected_object >= visible_start
15931                    && app.s3_state.selected_object < visible_end,
15932                "Selection {} should be visible in range [{}, {})",
15933                app.s3_state.selected_object,
15934                visible_start,
15935                visible_end
15936            );
15937        }
15938    }
15939
15940    #[test]
15941    fn test_s3_bucket_error_rows_counted_in_total() {
15942        use S3Bucket;
15943
15944        let mut app = test_app();
15945        app.current_service = Service::S3Buckets;
15946        app.service_selected = true;
15947
15948        // Create buckets
15949        app.s3_state.buckets.items = vec![
15950            S3Bucket {
15951                name: "bucket1".to_string(),
15952                region: "us-east-1".to_string(),
15953                creation_date: String::new(),
15954            },
15955            S3Bucket {
15956                name: "bucket2".to_string(),
15957                region: "us-east-1".to_string(),
15958                creation_date: String::new(),
15959            },
15960        ];
15961
15962        // Expand bucket1 with error (long error message that will wrap)
15963        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
15964        let long_error = "service error: unhandled error (PermanentRedirect): Error { code: PermanentRedirect, message: The bucket you are attempting to access must be addressed using the specified endpoint. Please send all future requests to this endpoint., request_id: 6D5VJ9TXYEMXSMXG, s3_extended_request_id: CGSwddO9ummjFYFHKyqNEU= }".to_string();
15965        app.s3_state
15966            .bucket_errors
15967            .insert("bucket1".to_string(), long_error.clone());
15968
15969        // Calculate total rows
15970        let total = app.calculate_total_bucket_rows();
15971
15972        // Should be: 2 buckets + error rows (long_error.len() / 120 rounded up)
15973        let error_rows = long_error.len().div_ceil(120);
15974        assert_eq!(total, 2 + error_rows);
15975    }
15976
15977    #[test]
15978    fn test_s3_bucket_with_error_can_be_collapsed() {
15979        use S3Bucket;
15980
15981        let mut app = test_app();
15982        app.current_service = Service::S3Buckets;
15983        app.service_selected = true;
15984        app.mode = Mode::Normal;
15985
15986        // Create bucket
15987        app.s3_state.buckets.items = vec![S3Bucket {
15988            name: "bucket1".to_string(),
15989            region: "us-east-1".to_string(),
15990            creation_date: String::new(),
15991        }];
15992
15993        // Expand bucket with error
15994        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
15995        let error = "service error: PermanentRedirect".to_string();
15996        app.s3_state
15997            .bucket_errors
15998            .insert("bucket1".to_string(), error);
15999
16000        // Select the bucket row (row 0) - error rows are not selectable
16001        app.s3_state.selected_row = 0;
16002
16003        // Press left arrow to collapse
16004        app.handle_action(Action::CollapseRow);
16005
16006        // Bucket should be collapsed
16007        assert!(!app.s3_state.expanded_prefixes.contains("bucket1"));
16008        // Selection should stay on bucket
16009        assert_eq!(app.s3_state.selected_row, 0);
16010    }
16011
16012    #[test]
16013    fn test_s3_bucket_collapse_on_bucket_row() {
16014        use S3Bucket;
16015
16016        let mut app = test_app();
16017        app.current_service = Service::S3Buckets;
16018        app.service_selected = true;
16019        app.mode = Mode::Normal;
16020
16021        // Create bucket
16022        app.s3_state.buckets.items = vec![S3Bucket {
16023            name: "bucket1".to_string(),
16024            region: "us-east-1".to_string(),
16025            creation_date: String::new(),
16026        }];
16027
16028        // Expand bucket with error
16029        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
16030        let error = "service error: PermanentRedirect".to_string();
16031        app.s3_state
16032            .bucket_errors
16033            .insert("bucket1".to_string(), error);
16034
16035        // Select the bucket row itself (row 0)
16036        app.s3_state.selected_row = 0;
16037
16038        // Press left arrow to collapse
16039        app.handle_action(Action::CollapseRow);
16040
16041        // Bucket should be collapsed
16042        assert!(!app.s3_state.expanded_prefixes.contains("bucket1"));
16043        // Selection should stay on bucket
16044        assert_eq!(app.s3_state.selected_row, 0);
16045    }
16046
16047    #[test]
16048    fn test_s3_bucket_collapse_adjusts_scroll_offset() {
16049        use S3Bucket;
16050
16051        let mut app = test_app();
16052        app.current_service = Service::S3Buckets;
16053        app.service_selected = true;
16054        app.mode = Mode::Normal;
16055
16056        // Create multiple buckets
16057        app.s3_state.buckets.items = (0..20)
16058            .map(|i| S3Bucket {
16059                name: format!("bucket{}", i),
16060                region: "us-east-1".to_string(),
16061                creation_date: String::new(),
16062            })
16063            .collect();
16064
16065        // Expand bucket 10 with a long error
16066        app.s3_state
16067            .expanded_prefixes
16068            .insert("bucket10".to_string());
16069        let long_error = "service error: unhandled error (PermanentRedirect): Error { code: PermanentRedirect, message: The bucket you are attempting to access must be addressed using the specified endpoint. Please send all future requests to this endpoint., request_id: 6D5VJ9TXYEMXSMXG }".to_string();
16070        app.s3_state
16071            .bucket_errors
16072            .insert("bucket10".to_string(), long_error.clone());
16073
16074        // Set viewport to 10 rows and scroll so bucket10 is at top
16075        app.s3_state.bucket_visible_rows.set(10);
16076        app.s3_state.bucket_scroll_offset = 10; // bucket10 is at row 10
16077
16078        // Select bucket10 (row 10) - error rows are not selectable
16079        app.s3_state.selected_row = 10;
16080
16081        // Press left arrow to collapse
16082        app.handle_action(Action::CollapseRow);
16083
16084        // Bucket should be collapsed
16085        assert!(!app.s3_state.expanded_prefixes.contains("bucket10"));
16086        // Selection should stay on bucket10 (row 10)
16087        assert_eq!(app.s3_state.selected_row, 10);
16088        // Scroll offset should be adjusted to show bucket10
16089        assert!(app.s3_state.selected_row >= app.s3_state.bucket_scroll_offset);
16090        assert!(
16091            app.s3_state.selected_row
16092                < app.s3_state.bucket_scroll_offset + app.s3_state.bucket_visible_rows.get()
16093        );
16094    }
16095
16096    #[test]
16097    fn test_s3_collapse_second_to_last_bucket_with_last_having_error() {
16098        use S3Bucket;
16099
16100        let mut app = test_app();
16101        app.current_service = Service::S3Buckets;
16102        app.service_selected = true;
16103        app.mode = Mode::Normal;
16104
16105        // Create 3 buckets
16106        app.s3_state.buckets.items = vec![
16107            S3Bucket {
16108                name: "bucket1".to_string(),
16109                region: "us-east-1".to_string(),
16110                creation_date: String::new(),
16111            },
16112            S3Bucket {
16113                name: "bucket2".to_string(),
16114                region: "us-east-1".to_string(),
16115                creation_date: String::new(),
16116            },
16117            S3Bucket {
16118                name: "bucket3".to_string(),
16119                region: "us-east-1".to_string(),
16120                creation_date: String::new(),
16121            },
16122        ];
16123
16124        // Expand bucket2 (second to last) with preview
16125        app.s3_state.expanded_prefixes.insert("bucket2".to_string());
16126        app.s3_state.bucket_preview.insert(
16127            "bucket2".to_string(),
16128            vec![
16129                S3Object {
16130                    key: "folder1/".to_string(),
16131                    is_prefix: true,
16132                    size: 0,
16133                    last_modified: String::new(),
16134                    storage_class: String::new(),
16135                },
16136                S3Object {
16137                    key: "file1.txt".to_string(),
16138                    is_prefix: false,
16139                    size: 100,
16140                    last_modified: String::new(),
16141                    storage_class: String::new(),
16142                },
16143            ],
16144        );
16145
16146        // Expand bucket3 (last) with error
16147        app.s3_state.expanded_prefixes.insert("bucket3".to_string());
16148        let error = "service error: PermanentRedirect".to_string();
16149        app.s3_state
16150            .bucket_errors
16151            .insert("bucket3".to_string(), error);
16152
16153        // Set viewport
16154        app.s3_state.bucket_visible_rows.set(10);
16155        app.s3_state.bucket_scroll_offset = 0;
16156
16157        // Select last item in bucket2 (row 3: bucket1, bucket2, folder1, file1)
16158        app.s3_state.selected_row = 3;
16159
16160        // Collapse - should move to parent (bucket2)
16161        app.handle_action(Action::CollapseRow);
16162
16163        // bucket2 should still be expanded (we only moved to parent, didn't collapse)
16164        assert!(app.s3_state.expanded_prefixes.contains("bucket2"));
16165        // Selection should move to bucket2
16166        assert_eq!(app.s3_state.selected_row, 1);
16167        // Selection should be visible
16168        assert!(app.s3_state.selected_row >= app.s3_state.bucket_scroll_offset);
16169        assert!(
16170            app.s3_state.selected_row
16171                < app.s3_state.bucket_scroll_offset + app.s3_state.bucket_visible_rows.get()
16172        );
16173    }
16174
16175    #[test]
16176    fn test_s3_collapse_bucket_with_error() {
16177        use S3Bucket;
16178
16179        let mut app = test_app();
16180        app.current_service = Service::S3Buckets;
16181        app.service_selected = true;
16182        app.mode = Mode::Normal;
16183
16184        app.s3_state.buckets.items = vec![
16185            S3Bucket {
16186                name: "bucket1".to_string(),
16187                region: "us-east-1".to_string(),
16188                creation_date: String::new(),
16189            },
16190            S3Bucket {
16191                name: "bucket2".to_string(),
16192                region: "us-east-1".to_string(),
16193                creation_date: String::new(),
16194            },
16195        ];
16196
16197        let error = "service error: unhandled error (PermanentRedirect)".to_string();
16198        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
16199        app.s3_state
16200            .bucket_errors
16201            .insert("bucket1".to_string(), error);
16202
16203        app.s3_state.bucket_visible_rows.set(10);
16204        app.s3_state.bucket_scroll_offset = 0;
16205
16206        // Select bucket1 (row 0) - error rows are not selectable
16207        app.s3_state.selected_row = 0;
16208
16209        // Collapse
16210        app.handle_action(Action::CollapseRow);
16211
16212        // bucket1 should be collapsed
16213        assert!(!app.s3_state.expanded_prefixes.contains("bucket1"));
16214        assert_eq!(app.s3_state.selected_row, 0);
16215    }
16216
16217    #[test]
16218    fn test_s3_collapse_row_with_multiple_error_buckets() {
16219        use S3Bucket;
16220
16221        let mut app = test_app();
16222        app.current_service = Service::S3Buckets;
16223        app.service_selected = true;
16224        app.mode = Mode::Normal;
16225
16226        app.s3_state.buckets.items = vec![
16227            S3Bucket {
16228                name: "bucket1".to_string(),
16229                region: "us-east-1".to_string(),
16230                creation_date: String::new(),
16231            },
16232            S3Bucket {
16233                name: "bucket2".to_string(),
16234                region: "us-east-1".to_string(),
16235                creation_date: String::new(),
16236            },
16237            S3Bucket {
16238                name: "bucket3".to_string(),
16239                region: "us-east-1".to_string(),
16240                creation_date: String::new(),
16241            },
16242        ];
16243
16244        let error = "service error: unhandled error (PermanentRedirect)".to_string();
16245
16246        // Expand bucket1 with error
16247        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
16248        app.s3_state
16249            .bucket_errors
16250            .insert("bucket1".to_string(), error.clone());
16251
16252        // Expand bucket3 with error
16253        app.s3_state.expanded_prefixes.insert("bucket3".to_string());
16254        app.s3_state
16255            .bucket_errors
16256            .insert("bucket3".to_string(), error.clone());
16257
16258        app.s3_state.bucket_visible_rows.set(30);
16259        app.s3_state.bucket_scroll_offset = 0;
16260
16261        // Row 0: bucket1 (expanded with error - error rows not selectable)
16262        // Row 1: bucket2
16263        // Row 2: bucket3 (expanded with error - error rows not selectable)
16264        // Select bucket3
16265        app.s3_state.selected_row = 2;
16266
16267        app.handle_action(Action::CollapseRow);
16268
16269        // bucket3 should be collapsed, NOT bucket1
16270        assert!(
16271            !app.s3_state.expanded_prefixes.contains("bucket3"),
16272            "bucket3 should be collapsed"
16273        );
16274        assert!(
16275            app.s3_state.expanded_prefixes.contains("bucket1"),
16276            "bucket1 should still be expanded"
16277        );
16278        assert_eq!(app.s3_state.selected_row, 2);
16279    }
16280
16281    #[test]
16282    fn test_s3_collapse_row_nested_only_collapses_one_level() {
16283        use S3Bucket;
16284
16285        let mut app = test_app();
16286        app.current_service = Service::S3Buckets;
16287        app.service_selected = true;
16288        app.mode = Mode::Normal;
16289
16290        app.s3_state.buckets.items = vec![S3Bucket {
16291            name: "bucket1".to_string(),
16292            region: "us-east-1".to_string(),
16293            creation_date: String::new(),
16294        }];
16295
16296        // Level 1: bucket -> level1/
16297        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
16298        app.s3_state.bucket_preview.insert(
16299            "bucket1".to_string(),
16300            vec![S3Object {
16301                key: "level1/".to_string(),
16302                size: 0,
16303                last_modified: "2024-01-01T00:00:00Z".to_string(),
16304                is_prefix: true,
16305                storage_class: String::new(),
16306            }],
16307        );
16308
16309        // Level 2: level1/ -> level2/
16310        app.s3_state.expanded_prefixes.insert("level1/".to_string());
16311        app.s3_state.prefix_preview.insert(
16312            "level1/".to_string(),
16313            vec![S3Object {
16314                key: "level1/level2/".to_string(),
16315                size: 0,
16316                last_modified: "2024-01-01T00:00:00Z".to_string(),
16317                is_prefix: true,
16318                storage_class: String::new(),
16319            }],
16320        );
16321
16322        // Level 3: level2/ -> file
16323        app.s3_state
16324            .expanded_prefixes
16325            .insert("level1/level2/".to_string());
16326        app.s3_state.prefix_preview.insert(
16327            "level1/level2/".to_string(),
16328            vec![S3Object {
16329                key: "level1/level2/file.txt".to_string(),
16330                size: 100,
16331                last_modified: "2024-01-01T00:00:00Z".to_string(),
16332                is_prefix: false,
16333                storage_class: String::new(),
16334            }],
16335        );
16336
16337        app.s3_state.bucket_visible_rows.set(10);
16338
16339        // Select level2/ (row 2: bucket, level1, level2)
16340        app.s3_state.selected_row = 2;
16341
16342        // Collapse - should only collapse level2/, not the entire bucket
16343        app.handle_action(Action::CollapseRow);
16344
16345        // level2/ should be collapsed
16346        assert!(!app.s3_state.expanded_prefixes.contains("level1/level2/"));
16347        // level1/ should still be expanded
16348        assert!(app.s3_state.expanded_prefixes.contains("level1/"));
16349        // bucket should still be expanded
16350        assert!(app.s3_state.expanded_prefixes.contains("bucket1"));
16351        // Selection should move to parent (level1/ at row 1)
16352        assert_eq!(app.s3_state.selected_row, 1);
16353    }
16354
16355    #[test]
16356    fn test_s3_collapse_row_deeply_nested_file() {
16357        use S3Bucket;
16358
16359        let mut app = test_app();
16360        app.current_service = Service::S3Buckets;
16361        app.service_selected = true;
16362        app.mode = Mode::Normal;
16363
16364        app.s3_state.buckets.items = vec![S3Bucket {
16365            name: "bucket1".to_string(),
16366            region: "us-east-1".to_string(),
16367            creation_date: String::new(),
16368        }];
16369
16370        // Level 1: bucket -> level1/
16371        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
16372        app.s3_state.bucket_preview.insert(
16373            "bucket1".to_string(),
16374            vec![S3Object {
16375                key: "level1/".to_string(),
16376                size: 0,
16377                last_modified: "2024-01-01T00:00:00Z".to_string(),
16378                is_prefix: true,
16379                storage_class: String::new(),
16380            }],
16381        );
16382
16383        // Level 2: level1/ -> level2/
16384        app.s3_state.expanded_prefixes.insert("level1/".to_string());
16385        app.s3_state.prefix_preview.insert(
16386            "level1/".to_string(),
16387            vec![S3Object {
16388                key: "level1/level2/".to_string(),
16389                size: 0,
16390                last_modified: "2024-01-01T00:00:00Z".to_string(),
16391                is_prefix: true,
16392                storage_class: String::new(),
16393            }],
16394        );
16395
16396        // Level 3: level2/ -> file
16397        app.s3_state
16398            .expanded_prefixes
16399            .insert("level1/level2/".to_string());
16400        app.s3_state.prefix_preview.insert(
16401            "level1/level2/".to_string(),
16402            vec![S3Object {
16403                key: "level1/level2/file.txt".to_string(),
16404                size: 100,
16405                last_modified: "2024-01-01T00:00:00Z".to_string(),
16406                is_prefix: false,
16407                storage_class: String::new(),
16408            }],
16409        );
16410
16411        app.s3_state.bucket_visible_rows.set(10);
16412
16413        // Select file (row 3: bucket, level1, level2, file)
16414        app.s3_state.selected_row = 3;
16415
16416        // Collapse - should move to parent (level2/)
16417        app.handle_action(Action::CollapseRow);
16418
16419        // All levels should still be expanded
16420        assert!(app.s3_state.expanded_prefixes.contains("level1/level2/"));
16421        assert!(app.s3_state.expanded_prefixes.contains("level1/"));
16422        assert!(app.s3_state.expanded_prefixes.contains("bucket1"));
16423        // Selection should move to parent (level2/ at row 2)
16424        assert_eq!(app.s3_state.selected_row, 2);
16425    }
16426
16427    #[test]
16428    fn test_s3_bucket_pagination_adjusts_scroll() {
16429        use S3Bucket;
16430
16431        let mut app = test_app();
16432        app.current_service = Service::S3Buckets;
16433        app.service_selected = true;
16434        app.mode = Mode::Normal;
16435
16436        // Create 150 buckets (3 pages with page size 50)
16437        app.s3_state.buckets.items = (0..150)
16438            .map(|i| S3Bucket {
16439                name: format!("bucket{:03}", i),
16440                region: "us-east-1".to_string(),
16441                creation_date: String::new(),
16442            })
16443            .collect();
16444
16445        app.s3_state.bucket_visible_rows.set(20);
16446        app.s3_state.selected_row = 0;
16447        app.s3_state.bucket_scroll_offset = 0;
16448
16449        // Go to page 2 (should select row 50 and scroll to show it)
16450        app.go_to_page(2);
16451
16452        assert_eq!(app.s3_state.selected_row, 50);
16453        // Scroll offset should be adjusted to show page 2
16454        assert_eq!(app.s3_state.bucket_scroll_offset, 50);
16455
16456        // Go to page 3 (should select row 100 and scroll to show it)
16457        app.go_to_page(3);
16458
16459        assert_eq!(app.s3_state.selected_row, 100);
16460        assert_eq!(app.s3_state.bucket_scroll_offset, 100);
16461
16462        // Go to page 1 (should select row 0 and scroll to top)
16463        app.go_to_page(1);
16464
16465        assert_eq!(app.s3_state.selected_row, 0);
16466        assert_eq!(app.s3_state.bucket_scroll_offset, 0);
16467    }
16468
16469    #[test]
16470    fn test_s3_bucket_pagination_uses_page_size() {
16471        use S3Bucket;
16472
16473        let mut app = test_app();
16474        app.current_service = Service::S3Buckets;
16475        app.service_selected = true;
16476        app.mode = Mode::Normal;
16477
16478        // Create 100 buckets
16479        app.s3_state.buckets.items = (0..100)
16480            .map(|i| S3Bucket {
16481                name: format!("bucket{:03}", i),
16482                region: "us-east-1".to_string(),
16483                creation_date: String::new(),
16484            })
16485            .collect();
16486
16487        app.s3_state.bucket_visible_rows.set(20);
16488        app.s3_state.selected_row = 0;
16489
16490        // Default page size is 50
16491        assert_eq!(app.s3_state.buckets.page_size.value(), 50);
16492
16493        // Go to page 2 with default page size (50)
16494        app.go_to_page(2);
16495        assert_eq!(app.s3_state.selected_row, 50);
16496        assert_eq!(app.s3_state.bucket_scroll_offset, 50);
16497
16498        // Change page size to 25
16499        app.s3_state.buckets.page_size = crate::common::PageSize::TwentyFive;
16500        assert_eq!(app.s3_state.buckets.page_size.value(), 25);
16501
16502        // Go to page 2 with new page size (25)
16503        app.go_to_page(2);
16504        assert_eq!(app.s3_state.selected_row, 25);
16505        assert_eq!(app.s3_state.bucket_scroll_offset, 25);
16506    }
16507
16508    #[test]
16509    fn test_s3_bucket_page_size_limits_visible_rows() {
16510        use S3Bucket;
16511
16512        let mut app = test_app();
16513        app.current_service = Service::S3Buckets;
16514        app.service_selected = true;
16515        app.mode = Mode::Normal;
16516
16517        // Create 100 buckets
16518        app.s3_state.buckets.items = (0..100)
16519            .map(|i| S3Bucket {
16520                name: format!("bucket{:03}", i),
16521                region: "us-east-1".to_string(),
16522                creation_date: String::new(),
16523            })
16524            .collect();
16525
16526        // Set page size to 10
16527        app.s3_state.buckets.page_size = crate::common::PageSize::Ten;
16528        assert_eq!(app.s3_state.buckets.page_size.value(), 10);
16529
16530        // Calculate total rows - should only count buckets on current page
16531        let total_rows = app.calculate_total_bucket_rows();
16532        // With 100 buckets and page size 10, we should see 10 buckets per page
16533        // But calculate_total_bucket_rows returns ALL rows, not just current page
16534        // This is the issue - we need to paginate the display
16535        assert!(total_rows >= 10, "Should have at least 10 rows");
16536    }
16537
16538    #[test]
16539    fn test_s3_bucket_tab_cycling_in_filter() {
16540        use crate::common::InputFocus;
16541
16542        let mut app = test_app();
16543        app.current_service = Service::S3Buckets;
16544        app.mode = Mode::FilterInput;
16545
16546        // Start at Filter
16547        assert_eq!(app.s3_state.input_focus, InputFocus::Filter);
16548
16549        // Tab to Pagination
16550        app.handle_action(Action::NextFilterFocus);
16551        assert_eq!(app.s3_state.input_focus, InputFocus::Pagination);
16552
16553        // Tab back to Filter
16554        app.handle_action(Action::NextFilterFocus);
16555        assert_eq!(app.s3_state.input_focus, InputFocus::Filter);
16556    }
16557
16558    #[test]
16559    fn test_s3_bucket_pagination_navigation_with_arrows() {
16560        use S3Bucket;
16561
16562        let mut app = test_app();
16563        app.current_service = Service::S3Buckets;
16564        app.mode = Mode::FilterInput;
16565
16566        // Create 100 buckets
16567        app.s3_state.buckets.items = (0..100)
16568            .map(|i| S3Bucket {
16569                name: format!("bucket{:03}", i),
16570                region: "us-east-1".to_string(),
16571                creation_date: String::new(),
16572            })
16573            .collect();
16574
16575        app.s3_state.buckets.page_size = crate::common::PageSize::Ten;
16576        app.s3_state.selected_row = 0;
16577
16578        // Focus pagination
16579        app.s3_state.input_focus = crate::common::InputFocus::Pagination;
16580
16581        // Right arrow should go to next page (row 10)
16582        app.handle_action(Action::NextItem);
16583        assert_eq!(app.s3_state.selected_row, 10);
16584
16585        // Right arrow again (row 20)
16586        app.handle_action(Action::NextItem);
16587        assert_eq!(app.s3_state.selected_row, 20);
16588
16589        // Left arrow should go back (row 10)
16590        app.handle_action(Action::PrevItem);
16591        assert_eq!(app.s3_state.selected_row, 10);
16592    }
16593
16594    #[test]
16595    fn test_s3_bucket_go_to_page_shows_correct_buckets() {
16596        use S3Bucket;
16597
16598        let mut app = test_app();
16599        app.current_service = Service::S3Buckets;
16600        app.service_selected = true;
16601        app.mode = Mode::Normal;
16602
16603        // Create 100 buckets
16604        app.s3_state.buckets.items = (0..100)
16605            .map(|i| S3Bucket {
16606                name: format!("bucket{:03}", i),
16607                region: "us-east-1".to_string(),
16608                creation_date: String::new(),
16609            })
16610            .collect();
16611
16612        app.s3_state.buckets.page_size = crate::common::PageSize::Ten;
16613
16614        // Go to page 2 (should show buckets 10-19)
16615        app.go_to_page(2);
16616        assert_eq!(app.s3_state.selected_row, 10);
16617
16618        // Go to page 5 (should show buckets 40-49)
16619        app.go_to_page(5);
16620        assert_eq!(app.s3_state.selected_row, 40);
16621    }
16622
16623    #[test]
16624    fn test_s3_bucket_left_right_arrows_change_pages() {
16625        use S3Bucket;
16626
16627        let mut app = test_app();
16628        app.current_service = Service::S3Buckets;
16629        app.mode = Mode::FilterInput;
16630
16631        // Create 100 buckets
16632        app.s3_state.buckets.items = (0..100)
16633            .map(|i| S3Bucket {
16634                name: format!("bucket{:03}", i),
16635                region: "us-east-1".to_string(),
16636                creation_date: String::new(),
16637            })
16638            .collect();
16639
16640        app.s3_state.buckets.page_size = crate::common::PageSize::Ten;
16641        app.s3_state.selected_row = 0;
16642        app.s3_state.input_focus = crate::common::InputFocus::Pagination;
16643
16644        // Right arrow (PageDown) should go to next page
16645        app.handle_action(Action::PageDown);
16646        assert_eq!(app.s3_state.selected_row, 10);
16647
16648        // Right arrow again
16649        app.handle_action(Action::PageDown);
16650        assert_eq!(app.s3_state.selected_row, 20);
16651
16652        // Left arrow (PageUp) should go back
16653        app.handle_action(Action::PageUp);
16654        assert_eq!(app.s3_state.selected_row, 10);
16655    }
16656
16657    #[test]
16658    fn test_s3_bucket_preview_uses_bucket_region_not_config_region() {
16659        // Regression: load_bucket_preview was falling back to self.config.region when
16660        // the bucket's stored region was empty (which it always is from list_buckets).
16661        // This caused IllegalLocationConstraintException for cross-region buckets.
16662        // The fix: resolve the actual region via get_bucket_location when region is empty.
16663        //
16664        // This test verifies the region stored in the bucket is used when available,
16665        // not the app's configured region.
16666        use S3Bucket;
16667        let mut app = test_app();
16668        app.config.region = "ap-southeast-3".to_string(); // user's configured region
16669
16670        // Bucket is actually in us-east-1 — different from app region
16671        app.s3_state.buckets.items = vec![S3Bucket {
16672            name: "my-bucket".to_string(),
16673            region: "us-east-1".to_string(), // correct bucket region stored
16674            creation_date: String::new(),
16675        }];
16676
16677        // The stored region must take precedence over config.region
16678        let bucket = app
16679            .s3_state
16680            .buckets
16681            .items
16682            .iter()
16683            .find(|b| b.name == "my-bucket")
16684            .unwrap();
16685        let effective_region = if bucket.region.is_empty() {
16686            app.config.region.as_str()
16687        } else {
16688            bucket.region.as_str()
16689        };
16690        assert_eq!(
16691            effective_region, "us-east-1",
16692            "load_bucket_preview must use bucket's region, not app config region"
16693        );
16694        assert_ne!(
16695            effective_region, "ap-southeast-3",
16696            "Must NOT fall back to configured region for cross-region buckets"
16697        );
16698    }
16699
16700    #[test]
16701    fn test_s3_scroll_follows_selection_after_bucket_expand() {
16702        use S3Bucket;
16703        use S3Object;
16704        // Regression: expanding a bucket below the visible area didn't update bucket_scroll_offset,
16705        // so the selection (now pointing to the first child row) left the visible area.
16706        let mut app = test_app();
16707        app.current_service = Service::S3Buckets;
16708        app.service_selected = true;
16709        app.mode = Mode::Normal;
16710
16711        // 5 buckets, visible area is 3 rows (bucket_visible_rows = 3)
16712        app.s3_state.buckets.items = (0..5)
16713            .map(|i| S3Bucket {
16714                name: format!("bucket{}", i),
16715                region: "us-east-1".to_string(),
16716                creation_date: String::new(),
16717            })
16718            .collect();
16719
16720        app.s3_state.bucket_visible_rows = std::cell::Cell::new(3);
16721
16722        // Select row 2 (third bucket), scroll offset = 0 → row 2 is visible
16723        app.s3_state.selected_row = 2;
16724        app.s3_state.bucket_scroll_offset = 0;
16725
16726        // Expand bucket2 — it gets 2 children, selected_row moves to 3
16727        app.s3_state.bucket_preview.insert(
16728            "bucket2".to_string(),
16729            vec![
16730                S3Object {
16731                    key: "file1.txt".to_string(),
16732                    is_prefix: false,
16733                    size: 100,
16734                    last_modified: String::new(),
16735                    storage_class: String::new(),
16736                },
16737                S3Object {
16738                    key: "file2.txt".to_string(),
16739                    is_prefix: false,
16740                    size: 200,
16741                    last_modified: String::new(),
16742                    storage_class: String::new(),
16743                },
16744            ],
16745        );
16746
16747        // Simulate expand: selected_row moves to first child (row 3)
16748        // After expand, row 3 must be within [scroll_offset, scroll_offset + visible_rows)
16749        // i.e. bucket_scroll_offset must be adjusted so row 3 is visible
16750        app.handle_action(Action::CollapseRow); // first collapse to reset
16751        app.handle_action(Action::NextPane); // expand
16752
16753        // selected_row should now be 3 (first child), scroll_offset must keep it visible
16754        let visible = app.s3_state.bucket_visible_rows.get();
16755        let in_view = app.s3_state.selected_row >= app.s3_state.bucket_scroll_offset
16756            && app.s3_state.selected_row < app.s3_state.bucket_scroll_offset + visible;
16757        assert!(
16758            in_view,
16759            "selected_row {} must be visible in [{}, {})",
16760            app.s3_state.selected_row,
16761            app.s3_state.bucket_scroll_offset,
16762            app.s3_state.bucket_scroll_offset + visible
16763        );
16764    }
16765
16766    #[test]
16767    fn test_s3_expand_without_preview_does_not_move_selection() {
16768        // Regression: when a bucket preview is not yet loaded (still loading),
16769        // right arrow must NOT move selection to "first child" (row_idx + 1).
16770        // Selection must stay on the bucket row until preview arrives.
16771        use S3Bucket;
16772        let mut app = test_app();
16773        app.current_service = Service::S3Buckets;
16774        app.service_selected = true;
16775        app.mode = Mode::Normal;
16776
16777        app.s3_state.buckets.items = vec![
16778            S3Bucket {
16779                name: "bucket0".to_string(),
16780                region: "us-east-1".to_string(),
16781                creation_date: String::new(),
16782            },
16783            S3Bucket {
16784                name: "bucket1".to_string(),
16785                region: "us-east-1".to_string(),
16786                creation_date: String::new(),
16787            },
16788        ];
16789        app.s3_state.selected_row = 0; // on bucket0
16790                                       // No preview loaded yet for bucket0
16791
16792        app.handle_action(Action::NextPane); // right arrow — expand, triggers loading
16793
16794        // Must stay on row 0 until preview arrives
16795        assert_eq!(
16796            app.s3_state.selected_row, 0,
16797            "Selection must not move before preview is loaded"
16798        );
16799        // Bucket must be marked as expanded and loading
16800        assert!(app.s3_state.expanded_prefixes.contains("bucket0"));
16801        assert!(app.s3_state.buckets.loading);
16802    }
16803
16804    #[test]
16805    fn test_s3_expand_with_preview_moves_to_first_child() {
16806        // After preview is loaded, right arrow must move selection to first child immediately.
16807        use crate::s3::Object as S3Object;
16808        use S3Bucket;
16809        let mut app = test_app();
16810        app.current_service = Service::S3Buckets;
16811        app.service_selected = true;
16812        app.mode = Mode::Normal;
16813
16814        app.s3_state.buckets.items = vec![
16815            S3Bucket {
16816                name: "bucket0".to_string(),
16817                region: "us-east-1".to_string(),
16818                creation_date: String::new(),
16819            },
16820            S3Bucket {
16821                name: "bucket1".to_string(),
16822                region: "us-east-1".to_string(),
16823                creation_date: String::new(),
16824            },
16825        ];
16826        app.s3_state.selected_row = 0;
16827        app.s3_state.bucket_preview.insert(
16828            "bucket0".to_string(),
16829            vec![S3Object {
16830                key: "file.txt".to_string(),
16831                is_prefix: false,
16832                size: 0,
16833                last_modified: String::new(),
16834                storage_class: String::new(),
16835            }],
16836        );
16837        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
16838
16839        // First right arrow: expands and immediately moves to first child (preview already loaded)
16840        app.handle_action(Action::NextPane);
16841        assert_eq!(app.s3_state.selected_row, 1, "Should move to first child");
16842    }
16843
16844    #[test]
16845    fn test_s3_expand_then_load_then_right_goes_to_first_child_not_sibling() {
16846        // Repro: right arrow → loading starts (selection stays on bucket) →
16847        // preview arrives → right arrow again → must go to first child (row 1),
16848        // NOT to next sibling bucket (row 2 if no children counted, or higher).
16849        use crate::s3::Object as S3Object;
16850        use S3Bucket;
16851        let mut app = test_app();
16852        app.current_service = Service::S3Buckets;
16853        app.service_selected = true;
16854        app.mode = Mode::Normal;
16855        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
16856
16857        app.s3_state.buckets.items = vec![
16858            S3Bucket {
16859                name: "bucket0".to_string(),
16860                region: "us-east-1".to_string(),
16861                creation_date: String::new(),
16862            },
16863            S3Bucket {
16864                name: "bucket1".to_string(),
16865                region: "us-east-1".to_string(),
16866                creation_date: String::new(),
16867            },
16868        ];
16869        app.s3_state.selected_row = 0;
16870
16871        // Step 1: right arrow — no preview yet, expands + loading
16872        app.handle_action(Action::NextPane);
16873        assert_eq!(
16874            app.s3_state.selected_row, 0,
16875            "Must stay on bucket0 while loading"
16876        );
16877        assert!(app.s3_state.buckets.loading);
16878
16879        // Step 2: simulate preview arriving (like main.rs does after load_bucket_preview)
16880        app.s3_state.bucket_preview.insert(
16881            "bucket0".to_string(),
16882            vec![S3Object {
16883                key: "file.txt".to_string(),
16884                is_prefix: false,
16885                size: 0,
16886                last_modified: String::new(),
16887                storage_class: String::new(),
16888            }],
16889        );
16890        app.s3_state.buckets.loading = false;
16891
16892        // Step 3: right arrow again — must go to first child (row 1), not sibling (row 2)
16893        app.handle_action(Action::NextPane);
16894        assert_eq!(
16895            app.s3_state.selected_row, 1,
16896            "After preview loads, right arrow must select first child (row 1), not sibling (row 2)"
16897        );
16898    }
16899
16900    #[test]
16901    fn test_s3_first_expand_never_moves_selection_before_children_visible() {
16902        // Rule: selection must NEVER move to a new row until the children rows
16903        // are actually present in the data (preview loaded). This prevents
16904        // showing a "selected next sibling" before children appear.
16905        use S3Bucket;
16906        let mut app = test_app();
16907        app.current_service = Service::S3Buckets;
16908        app.service_selected = true;
16909        app.mode = Mode::Normal;
16910        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
16911
16912        app.s3_state.buckets.items = vec![
16913            S3Bucket {
16914                name: "bucket0".to_string(),
16915                region: "us-east-1".to_string(),
16916                creation_date: String::new(),
16917            },
16918            S3Bucket {
16919                name: "bucket1".to_string(),
16920                region: "us-east-1".to_string(),
16921                creation_date: String::new(),
16922            },
16923        ];
16924        // No preview for bucket0
16925        app.s3_state.selected_row = 0;
16926
16927        // First expand (right arrow = ExpandRow): no preview → must NOT move selection
16928        app.handle_action(Action::ExpandRow);
16929        assert_eq!(
16930            app.s3_state.selected_row, 0,
16931            "Selection must stay on bucket0 until children are visible"
16932        );
16933        assert!(app.s3_state.expanded_prefixes.contains("bucket0"));
16934        assert!(app.s3_state.buckets.loading);
16935    }
16936
16937    #[test]
16938    fn test_s3_after_preview_loads_selection_moves_to_first_child() {
16939        // After first expand triggers loading, once preview arrives the selection
16940        // must automatically advance to the first child.
16941        use crate::s3::Object as S3Object;
16942        use S3Bucket;
16943        let mut app = test_app();
16944        app.current_service = Service::S3Buckets;
16945        app.service_selected = true;
16946        app.mode = Mode::Normal;
16947        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
16948        app.s3_state.buckets.items = vec![
16949            S3Bucket {
16950                name: "b0".to_string(),
16951                region: "us-east-1".to_string(),
16952                creation_date: String::new(),
16953            },
16954            S3Bucket {
16955                name: "b1".to_string(),
16956                region: "us-east-1".to_string(),
16957                creation_date: String::new(),
16958            },
16959        ];
16960        app.s3_state.selected_row = 0;
16961
16962        // Step 1: right arrow — no preview, expand + loading, selection stays
16963        app.handle_action(Action::ExpandRow);
16964        assert_eq!(
16965            app.s3_state.selected_row, 0,
16966            "Must stay on bucket while loading"
16967        );
16968
16969        // Step 2: simulate preview arriving (main.rs calls load_bucket_preview)
16970        app.s3_state.bucket_preview.insert(
16971            "b0".to_string(),
16972            vec![S3Object {
16973                key: "f.txt".to_string(),
16974                is_prefix: false,
16975                size: 0,
16976                last_modified: String::new(),
16977                storage_class: String::new(),
16978            }],
16979        );
16980        // After load, app must advance selection to first child
16981        app.after_bucket_preview_loaded("b0");
16982        assert_eq!(
16983            app.s3_state.selected_row, 1,
16984            "After preview loads, selection must advance to first child (row 1)"
16985        );
16986    }
16987
16988    #[test]
16989    fn test_s3_right_arrow_on_expanded_loaded_bucket_enters_first_child() {
16990        // When bucket is already expanded AND preview is loaded, right arrow
16991        // must move selection to first child — NOT collapse the bucket.
16992        use crate::s3::Object as S3Object;
16993        use S3Bucket;
16994        let mut app = test_app();
16995        app.current_service = Service::S3Buckets;
16996        app.service_selected = true;
16997        app.mode = Mode::Normal;
16998        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
16999        app.s3_state.buckets.items = vec![
17000            S3Bucket {
17001                name: "b0".to_string(),
17002                region: "us-east-1".to_string(),
17003                creation_date: String::new(),
17004            },
17005            S3Bucket {
17006                name: "b1".to_string(),
17007                region: "us-east-1".to_string(),
17008                creation_date: String::new(),
17009            },
17010        ];
17011        app.s3_state.expanded_prefixes.insert("b0".to_string());
17012        app.s3_state.bucket_preview.insert(
17013            "b0".to_string(),
17014            vec![S3Object {
17015                key: "f.txt".to_string(),
17016                is_prefix: false,
17017                size: 0,
17018                last_modified: String::new(),
17019                storage_class: String::new(),
17020            }],
17021        );
17022        app.s3_state.selected_row = 0;
17023
17024        app.handle_action(Action::ExpandRow);
17025
17026        // Must enter first child, not collapse
17027        assert!(
17028            app.s3_state.expanded_prefixes.contains("b0"),
17029            "Must stay expanded"
17030        );
17031        assert_eq!(app.s3_state.selected_row, 1, "Must move to first child");
17032    }
17033
17034    #[test]
17035    fn test_s3_second_expand_press_collapses_when_already_expanded_with_children() {
17036        // Collapse is done via left arrow (CollapseRow), not right arrow.
17037        // Right arrow on expanded+loaded bucket enters first child.
17038        use crate::s3::Object as S3Object;
17039        use S3Bucket;
17040        let mut app = test_app();
17041        app.current_service = Service::S3Buckets;
17042        app.service_selected = true;
17043        app.mode = Mode::Normal;
17044        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
17045        app.s3_state.buckets.items = vec![S3Bucket {
17046            name: "bucket0".to_string(),
17047            region: "us-east-1".to_string(),
17048            creation_date: String::new(),
17049        }];
17050        app.s3_state.expanded_prefixes.insert("bucket0".to_string());
17051        app.s3_state.bucket_preview.insert(
17052            "bucket0".to_string(),
17053            vec![S3Object {
17054                key: "file.txt".to_string(),
17055                is_prefix: false,
17056                size: 0,
17057                last_modified: String::new(),
17058                storage_class: String::new(),
17059            }],
17060        );
17061        app.s3_state.selected_row = 0;
17062
17063        // Left arrow collapses
17064        app.handle_action(Action::CollapseRow);
17065        assert!(
17066            !app.s3_state.expanded_prefixes.contains("bucket0"),
17067            "Left arrow must collapse the bucket"
17068        );
17069        assert_eq!(app.s3_state.selected_row, 0);
17070    }
17071
17072    #[test]
17073    fn test_s3_expand_row_index_correct_when_prior_bucket_has_error() {
17074        // Repro: bucket0 is expanded and has an error message (1+ error rows).
17075        // next_pane uses `continue` for error buckets, skipping their error rows.
17076        // But calculate_filtered_bucket_rows counts error rows.
17077        // This causes selected_row=2 to map to bucket2 in next_pane
17078        // but to the error row of bucket0 visually — wrong expansion target.
17079        use S3Bucket;
17080        let mut app = test_app();
17081        app.current_service = Service::S3Buckets;
17082        app.service_selected = true;
17083        app.mode = Mode::Normal;
17084        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
17085
17086        app.s3_state.buckets.items = vec![
17087            S3Bucket {
17088                name: "bucket0".to_string(),
17089                region: "us-east-1".to_string(),
17090                creation_date: String::new(),
17091            },
17092            S3Bucket {
17093                name: "bucket1".to_string(),
17094                region: "us-east-1".to_string(),
17095                creation_date: String::new(),
17096            },
17097            S3Bucket {
17098                name: "bucket2".to_string(),
17099                region: "us-east-1".to_string(),
17100                creation_date: String::new(),
17101            },
17102        ];
17103
17104        // bucket0 is expanded with an error (1 error row)
17105        app.s3_state.expanded_prefixes.insert("bucket0".to_string());
17106        app.s3_state
17107            .bucket_errors
17108            .insert("bucket0".to_string(), "AccessDenied".to_string());
17109
17110        // Visual rows: 0=bucket0, 1=⚠️error, 2=bucket1, 3=bucket2
17111        // calculate_filtered_bucket_rows = 4
17112        let total = crate::ui::s3::calculate_filtered_bucket_rows(&app);
17113        assert_eq!(
17114            total, 4,
17115            "Should be 4 rows: bucket0 + error + bucket1 + bucket2"
17116        );
17117
17118        // User selects bucket2 (visual row 3)
17119        app.s3_state.selected_row = 3;
17120
17121        // bucket2 has a preview
17122        use crate::s3::Object as S3Object;
17123        app.s3_state.bucket_preview.insert(
17124            "bucket2".to_string(),
17125            vec![S3Object {
17126                key: "file.txt".to_string(),
17127                is_prefix: false,
17128                size: 0,
17129                last_modified: String::new(),
17130                storage_class: String::new(),
17131            }],
17132        );
17133
17134        app.handle_action(Action::NextPane);
17135
17136        // Must expand bucket2 (row 3), select its first child (row 4)
17137        assert!(
17138            app.s3_state.expanded_prefixes.contains("bucket2"),
17139            "Must expand bucket2, not bucket1"
17140        );
17141        assert_eq!(
17142            app.s3_state.selected_row, 4,
17143            "First child of bucket2 must be row 4"
17144        );
17145    }
17146
17147    #[test]
17148    fn test_s3_next_pane_row_count_matches_calculate_filtered_bucket_rows() {
17149        // The row index next_pane uses to find the selected bucket must match
17150        // the row index that calculate_filtered_bucket_rows produces.
17151        // If they diverge, selecting row N expands a different bucket than expected.
17152        use crate::s3::Object as S3Object;
17153        use S3Bucket;
17154        let mut app = test_app();
17155        app.current_service = Service::S3Buckets;
17156        app.service_selected = true;
17157        app.mode = Mode::Normal;
17158        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
17159
17160        app.s3_state.buckets.items = vec![
17161            S3Bucket {
17162                name: "bucket0".to_string(),
17163                region: "us-east-1".to_string(),
17164                creation_date: String::new(),
17165            },
17166            S3Bucket {
17167                name: "bucket1".to_string(),
17168                region: "us-east-1".to_string(),
17169                creation_date: String::new(),
17170            },
17171            S3Bucket {
17172                name: "bucket2".to_string(),
17173                region: "us-east-1".to_string(),
17174                creation_date: String::new(),
17175            },
17176        ];
17177
17178        // bucket0 expanded + loaded with 3 children
17179        app.s3_state.expanded_prefixes.insert("bucket0".to_string());
17180        app.s3_state.bucket_preview.insert(
17181            "bucket0".to_string(),
17182            vec![
17183                S3Object {
17184                    key: "a.txt".to_string(),
17185                    is_prefix: false,
17186                    size: 0,
17187                    last_modified: String::new(),
17188                    storage_class: String::new(),
17189                },
17190                S3Object {
17191                    key: "b.txt".to_string(),
17192                    is_prefix: false,
17193                    size: 0,
17194                    last_modified: String::new(),
17195                    storage_class: String::new(),
17196                },
17197                S3Object {
17198                    key: "c.txt".to_string(),
17199                    is_prefix: false,
17200                    size: 0,
17201                    last_modified: String::new(),
17202                    storage_class: String::new(),
17203                },
17204            ],
17205        );
17206
17207        // bucket1 expanded + loading (no preview yet)
17208        app.s3_state.expanded_prefixes.insert("bucket1".to_string());
17209
17210        // calculate_filtered_bucket_rows: 3 buckets + 3 children of bucket0 + 0 loading = 6
17211        let total = crate::ui::s3::calculate_filtered_bucket_rows(&app);
17212        assert_eq!(
17213            total, 6,
17214            "Total rows: bucket0(1)+3children+bucket1(1)+bucket2(1)=6"
17215        );
17216
17217        // Visual rows: 0=bucket0, 1=a, 2=b, 3=c, 4=bucket1(loading), 5=bucket2
17218        // Select bucket2 (row 5)
17219        app.s3_state.selected_row = 5;
17220
17221        // bucket2 has preview
17222        app.s3_state.bucket_preview.insert(
17223            "bucket2".to_string(),
17224            vec![S3Object {
17225                key: "x.txt".to_string(),
17226                is_prefix: false,
17227                size: 0,
17228                last_modified: String::new(),
17229                storage_class: String::new(),
17230            }],
17231        );
17232
17233        app.handle_action(Action::NextPane);
17234
17235        assert!(
17236            app.s3_state.expanded_prefixes.contains("bucket2"),
17237            "Must expand bucket2, not bucket1"
17238        );
17239        assert_eq!(
17240            app.s3_state.selected_row, 6,
17241            "First child of bucket2 must be row 6"
17242        );
17243    }
17244
17245    #[test]
17246    fn test_s3_expand_row_index_correct_when_prior_bucket_has_loaded_children() {
17247        // Repro: bucket0 is expanded AND has loaded children (e.g. 2 objects).
17248        // This means visual rows are: 0=bucket0, 1=child0, 2=child1, 3=bucket1, 4=bucket2
17249        // If user selects bucket2 (visual row 4) and presses right, next_pane must
17250        // expand bucket2 — NOT a child of bucket0 (which is at row_idx 4 in old logic).
17251        use crate::s3::Object as S3Object;
17252        use S3Bucket;
17253        let mut app = test_app();
17254        app.current_service = Service::S3Buckets;
17255        app.service_selected = true;
17256        app.mode = Mode::Normal;
17257        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
17258
17259        app.s3_state.buckets.items = vec![
17260            S3Bucket {
17261                name: "bucket0".to_string(),
17262                region: "us-east-1".to_string(),
17263                creation_date: String::new(),
17264            },
17265            S3Bucket {
17266                name: "bucket1".to_string(),
17267                region: "us-east-1".to_string(),
17268                creation_date: String::new(),
17269            },
17270            S3Bucket {
17271                name: "bucket2".to_string(),
17272                region: "us-east-1".to_string(),
17273                creation_date: String::new(),
17274            },
17275        ];
17276
17277        // bucket0 is expanded AND has 2 children loaded
17278        app.s3_state.expanded_prefixes.insert("bucket0".to_string());
17279        app.s3_state.bucket_preview.insert(
17280            "bucket0".to_string(),
17281            vec![
17282                S3Object {
17283                    key: "a.txt".to_string(),
17284                    is_prefix: false,
17285                    size: 0,
17286                    last_modified: String::new(),
17287                    storage_class: String::new(),
17288                },
17289                S3Object {
17290                    key: "b.txt".to_string(),
17291                    is_prefix: false,
17292                    size: 0,
17293                    last_modified: String::new(),
17294                    storage_class: String::new(),
17295                },
17296            ],
17297        );
17298
17299        // Visual rows: 0=bucket0, 1=a.txt, 2=b.txt, 3=bucket1, 4=bucket2
17300        // User selects bucket2 (visual row 4)
17301        app.s3_state.selected_row = 4;
17302
17303        // bucket2 has preview ready
17304        app.s3_state.bucket_preview.insert(
17305            "bucket2".to_string(),
17306            vec![S3Object {
17307                key: "c.txt".to_string(),
17308                is_prefix: false,
17309                size: 0,
17310                last_modified: String::new(),
17311                storage_class: String::new(),
17312            }],
17313        );
17314
17315        app.handle_action(Action::NextPane);
17316
17317        // Must expand bucket2 (row 4) and move to its first child (row 5)
17318        assert!(
17319            app.s3_state.expanded_prefixes.contains("bucket2"),
17320            "bucket2 must be expanded"
17321        );
17322        assert_eq!(
17323            app.s3_state.selected_row, 5,
17324            "Must select first child of bucket2 (row 5), not some wrong row"
17325        );
17326        assert!(
17327            !app.s3_state.expanded_prefixes.contains("bucket1"),
17328            "bucket1 must NOT be expanded"
17329        );
17330    }
17331
17332    #[test]
17333    fn test_s3_expand_with_empty_preview_stays_on_bucket() {
17334        // Repro: bucket has a preview but it's EMPTY (no objects at root).
17335        // After expand, selected_row must stay on the bucket row — NOT advance to next sibling.
17336        use S3Bucket;
17337        let mut app = test_app();
17338        app.current_service = Service::S3Buckets;
17339        app.service_selected = true;
17340        app.mode = Mode::Normal;
17341        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
17342
17343        app.s3_state.buckets.items = vec![
17344            S3Bucket {
17345                name: "bucket0".to_string(),
17346                region: "us-east-1".to_string(),
17347                creation_date: String::new(),
17348            },
17349            S3Bucket {
17350                name: "bucket1".to_string(),
17351                region: "us-east-1".to_string(),
17352                creation_date: String::new(),
17353            },
17354            S3Bucket {
17355                name: "bucket2".to_string(),
17356                region: "us-east-1".to_string(),
17357                creation_date: String::new(),
17358            },
17359        ];
17360        // bucket1 has empty preview (bucket exists but no root-level objects)
17361        app.s3_state
17362            .bucket_preview
17363            .insert("bucket1".to_string(), vec![]);
17364        app.s3_state.selected_row = 1; // on bucket1
17365
17366        app.handle_action(Action::NextPane);
17367
17368        // Must stay on bucket1 (row 1) — no children to enter
17369        assert_eq!(
17370            app.s3_state.selected_row, 1,
17371            "Empty bucket: selection must stay on bucket row, not advance to next sibling"
17372        );
17373        assert!(
17374            app.s3_state.expanded_prefixes.contains("bucket1"),
17375            "Bucket must be expanded (showing ▼)"
17376        );
17377    }
17378
17379    #[test]
17380    fn test_s3_expand_with_preceding_loading_buckets_on_prior_page() {
17381        // Repro: user is on page 2 (scroll_offset=50). Some buckets on page 1
17382        // are expanded+loading. The next_pane traversal iterates all buckets from 0,
17383        // and each loading bucket on page 1 still adds a phantom row, shifting
17384        // the row where selected_row=55 actually lands.
17385        use crate::s3::Object as S3Object;
17386        use S3Bucket;
17387        let mut app = test_app();
17388        app.current_service = Service::S3Buckets;
17389        app.service_selected = true;
17390        app.mode = Mode::Normal;
17391        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
17392
17393        // 60 buckets
17394        app.s3_state.buckets.items = (0..60)
17395            .map(|i| S3Bucket {
17396                name: format!("bucket{:02}", i),
17397                region: "us-east-1".to_string(),
17398                creation_date: String::new(),
17399            })
17400            .collect();
17401
17402        // Buckets 0-2 on page 1 are expanded+loading (no preview)
17403        for i in 0..3usize {
17404            app.s3_state
17405                .expanded_prefixes
17406                .insert(format!("bucket{:02}", i));
17407        }
17408
17409        app.s3_state.bucket_scroll_offset = 50;
17410
17411        // bucket55 has preview loaded
17412        app.s3_state.bucket_preview.insert(
17413            "bucket55".to_string(),
17414            vec![S3Object {
17415                key: "file.txt".to_string(),
17416                is_prefix: false,
17417                size: 0,
17418                last_modified: String::new(),
17419                storage_class: String::new(),
17420            }],
17421        );
17422
17423        // selected_row = 55 (bucket55, visible row 5 on page 2)
17424        app.s3_state.selected_row = 55;
17425
17426        app.handle_action(Action::NextPane);
17427
17428        // Must expand bucket55, select its first child (row 56)
17429        assert!(
17430            app.s3_state.expanded_prefixes.contains("bucket55"),
17431            "bucket55 must be expanded"
17432        );
17433        assert_eq!(
17434            app.s3_state.selected_row, 56,
17435            "Must select first child of bucket55 (row 56)"
17436        );
17437    }
17438
17439    #[test]
17440    fn test_s3_expand_with_multiple_preceding_loading_buckets() {
17441        // Repro: multiple buckets before the selected one are expanded+loading.
17442        // Each was incorrectly adding a phantom row causing selection to point to wrong bucket.
17443        use crate::s3::Object as S3Object;
17444        use S3Bucket;
17445        let mut app = test_app();
17446        app.current_service = Service::S3Buckets;
17447        app.service_selected = true;
17448        app.mode = Mode::Normal;
17449        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
17450
17451        app.s3_state.buckets.items = (0..5)
17452            .map(|i| S3Bucket {
17453                name: format!("bucket{}", i),
17454                region: "us-east-1".to_string(),
17455                creation_date: String::new(),
17456            })
17457            .collect();
17458
17459        // Buckets 0, 1, 2 are expanded+loading (no preview)
17460        for i in 0..3 {
17461            app.s3_state
17462                .expanded_prefixes
17463                .insert(format!("bucket{}", i));
17464        }
17465
17466        // bucket3 has preview loaded
17467        app.s3_state.bucket_preview.insert(
17468            "bucket3".to_string(),
17469            vec![S3Object {
17470                key: "file.txt".to_string(),
17471                is_prefix: false,
17472                size: 0,
17473                last_modified: String::new(),
17474                storage_class: String::new(),
17475            }],
17476        );
17477
17478        // Visual: bucket0 (row 0), bucket1 (row 1), bucket2 (row 2), bucket3 (row 3), bucket4 (row 4)
17479        // No loading rows visible since previews not loaded
17480        app.s3_state.selected_row = 3; // bucket3
17481
17482        app.handle_action(Action::NextPane);
17483
17484        // Must expand bucket3, move to its first child (row 4)
17485        assert!(
17486            app.s3_state.expanded_prefixes.contains("bucket3"),
17487            "bucket3 must be expanded"
17488        );
17489        assert_eq!(
17490            app.s3_state.selected_row, 4,
17491            "Must select first child of bucket3 (row 4)"
17492        );
17493    }
17494
17495    #[test]
17496    fn test_s3_expand_with_preceding_loading_bucket_selects_correct_row() {
17497        // Repro: bucket0 is expanded+loading (in expanded_prefixes, no preview).
17498        // next_pane's traversal adds a phantom row for bucket0's loading state.
17499        // This shifts row_idx so that selecting bucket1 (visual row 1) actually
17500        // points to bucket2 (visual row 2) in the traversal — wrong expansion target.
17501        use crate::s3::Object as S3Object;
17502        use S3Bucket;
17503        let mut app = test_app();
17504        app.current_service = Service::S3Buckets;
17505        app.service_selected = true;
17506        app.mode = Mode::Normal;
17507        app.s3_state.bucket_visible_rows = std::cell::Cell::new(10);
17508
17509        app.s3_state.buckets.items = vec![
17510            S3Bucket {
17511                name: "bucket0".to_string(),
17512                region: "us-east-1".to_string(),
17513                creation_date: String::new(),
17514            },
17515            S3Bucket {
17516                name: "bucket1".to_string(),
17517                region: "us-east-1".to_string(),
17518                creation_date: String::new(),
17519            },
17520            S3Bucket {
17521                name: "bucket2".to_string(),
17522                region: "us-east-1".to_string(),
17523                creation_date: String::new(),
17524            },
17525        ];
17526
17527        // bucket0 is expanded+loading (in expanded_prefixes but NO preview)
17528        app.s3_state.expanded_prefixes.insert("bucket0".to_string());
17529        app.s3_state.buckets.loading = true;
17530
17531        // bucket1's preview is loaded
17532        app.s3_state.bucket_preview.insert(
17533            "bucket1".to_string(),
17534            vec![S3Object {
17535                key: "file.txt".to_string(),
17536                is_prefix: false,
17537                size: 0,
17538                last_modified: String::new(),
17539                storage_class: String::new(),
17540            }],
17541        );
17542
17543        // User selects bucket1 (visual row 1 = bucket row, bucket0 has no visible children)
17544        app.s3_state.selected_row = 1;
17545
17546        // Expand bucket1
17547        app.handle_action(Action::NextPane);
17548
17549        // Must expand bucket1 (not bucket2), so selected_row = 2 (first child of bucket1)
17550        assert_eq!(
17551            app.s3_state.selected_row, 2,
17552            "Must expand bucket1's first child (row 2), not bucket2 (row 3)"
17553        );
17554        assert!(
17555            app.s3_state.expanded_prefixes.contains("bucket1"),
17556            "bucket1 must be in expanded_prefixes"
17557        );
17558    }
17559
17560    #[test]
17561    fn test_s3_expand_last_row_on_page_shows_first_child_not_next_page() {
17562        // Repro: user selects the last bucket on a page (e.g. row 49 with page size 50),
17563        // expands it. selected_row becomes 50 (first child). But scroll_offset is still 0,
17564        // so row 50 is off screen — visually the next bucket (row 50 on next page) appears selected.
17565        // Fix: after expanding the last row on a visible page, scroll must advance to show first child.
17566        use crate::s3::Object as S3Object;
17567        use S3Bucket;
17568        let mut app = test_app();
17569        app.current_service = Service::S3Buckets;
17570        app.service_selected = true;
17571        app.mode = Mode::Normal;
17572
17573        // 3 buckets, visible area = 2 rows (tight to force the issue)
17574        app.s3_state.buckets.items = vec![
17575            S3Bucket {
17576                name: "bucket0".to_string(),
17577                region: "us-east-1".to_string(),
17578                creation_date: String::new(),
17579            },
17580            S3Bucket {
17581                name: "bucket1".to_string(),
17582                region: "us-east-1".to_string(),
17583                creation_date: String::new(),
17584            },
17585            S3Bucket {
17586                name: "bucket2".to_string(),
17587                region: "us-east-1".to_string(),
17588                creation_date: String::new(),
17589            },
17590        ];
17591        app.s3_state.bucket_visible_rows = std::cell::Cell::new(2);
17592        // Select the last visible bucket (row 1), scroll_offset = 0 → rows 0 and 1 visible
17593        app.s3_state.selected_row = 1;
17594        app.s3_state.bucket_scroll_offset = 0;
17595
17596        // Preview already loaded
17597        app.s3_state.bucket_preview.insert(
17598            "bucket1".to_string(),
17599            vec![S3Object {
17600                key: "file.txt".to_string(),
17601                is_prefix: false,
17602                size: 0,
17603                last_modified: String::new(),
17604                storage_class: String::new(),
17605            }],
17606        );
17607
17608        app.handle_action(Action::NextPane); // right arrow — expand
17609
17610        // selected_row must be 2 (first child of bucket1)
17611        assert_eq!(app.s3_state.selected_row, 2, "Must select first child");
17612
17613        // scroll_offset must have advanced so row 2 is visible
17614        let visible = app.s3_state.bucket_visible_rows.get();
17615        let in_view = app.s3_state.selected_row >= app.s3_state.bucket_scroll_offset
17616            && app.s3_state.selected_row < app.s3_state.bucket_scroll_offset + visible;
17617        assert!(
17618            in_view,
17619            "First child row {} must be visible in [{}, {})",
17620            app.s3_state.selected_row,
17621            app.s3_state.bucket_scroll_offset,
17622            app.s3_state.bucket_scroll_offset + visible
17623        );
17624    }
17625
17626    #[test]
17627    fn test_apig_detail_tab_navigation() {
17628        use crate::apig::api::RestApi;
17629        use crate::ui::apig::ApiDetailTab;
17630
17631        let mut app = test_app();
17632        app.current_service = Service::ApiGatewayApis;
17633        app.apig_state.current_api = Some(RestApi {
17634            id: "test123".to_string(),
17635            name: "Test API".to_string(),
17636            description: "Test".to_string(),
17637            created_date: "2024-01-01".to_string(),
17638            api_key_source: "HEADER".to_string(),
17639            endpoint_configuration: "REGIONAL".to_string(),
17640            protocol_type: "REST".to_string(),
17641            disable_execute_api_endpoint: false,
17642            status: "AVAILABLE".to_string(),
17643        });
17644
17645        // Should start on Routes tab
17646        assert_eq!(app.apig_state.detail_tab, ApiDetailTab::Routes);
17647
17648        // Next tab - should stay on Routes (only tab)
17649        app.handle_action(Action::NextDetailTab);
17650        assert_eq!(app.apig_state.detail_tab, ApiDetailTab::Routes);
17651
17652        // Prev tab - should stay on Routes (only tab)
17653        app.handle_action(Action::PrevDetailTab);
17654        assert_eq!(app.apig_state.detail_tab, ApiDetailTab::Routes);
17655
17656        // Another next
17657        app.handle_action(Action::NextDetailTab);
17658        assert_eq!(app.apig_state.detail_tab, ApiDetailTab::Routes);
17659
17660        // Prev tab should stay on Routes (only tab)
17661        app.handle_action(Action::PrevDetailTab);
17662        assert_eq!(app.apig_state.detail_tab, ApiDetailTab::Routes);
17663
17664        // Next tab should stay on Routes (only tab)
17665        app.handle_action(Action::NextDetailTab);
17666        assert_eq!(app.apig_state.detail_tab, ApiDetailTab::Routes);
17667    }
17668
17669    #[test]
17670    fn test_apig_routes_expand_collapse() {
17671        use crate::apig::api::RestApi;
17672        use crate::apig::route::Route;
17673        use crate::ui::apig::ApiDetailTab;
17674
17675        let mut app = test_app();
17676        app.current_service = Service::ApiGatewayApis;
17677        app.apig_state.current_api = Some(RestApi {
17678            id: "test123".to_string(),
17679            name: "Test API".to_string(),
17680            description: "Test".to_string(),
17681            created_date: "2024-01-01".to_string(),
17682            api_key_source: "HEADER".to_string(),
17683            endpoint_configuration: "REGIONAL".to_string(),
17684            protocol_type: "HTTP".to_string(),
17685            disable_execute_api_endpoint: false,
17686            status: "AVAILABLE".to_string(),
17687        });
17688        app.apig_state.detail_tab = ApiDetailTab::Routes;
17689
17690        // Create hierarchy with virtual parent
17691        let virtual_parent = Route {
17692            route_id: "virtual_/api".to_string(),
17693            route_key: "/api".to_string(),
17694            target: String::new(), // Empty target = virtual parent
17695            authorization_type: String::new(),
17696            api_key_required: false,
17697            display_name: String::new(),
17698            arn: String::new(),
17699        };
17700        let child_route = Route {
17701            route_id: "1".to_string(),
17702            route_key: "/api/users".to_string(),
17703            target: "integration1".to_string(),
17704            authorization_type: "NONE".to_string(),
17705            api_key_required: false,
17706            display_name: String::new(),
17707            arn: String::new(),
17708        };
17709
17710        app.apig_state.routes.items = vec![virtual_parent];
17711        app.apig_state
17712            .route_children
17713            .insert("/api".to_string(), vec![child_route]);
17714
17715        // Initially no routes expanded
17716        assert!(app.apig_state.expanded_routes.is_empty());
17717
17718        // Expand virtual parent
17719        app.apig_state.routes.selected = 0;
17720        app.handle_action(Action::ExpandRow);
17721        assert!(app.apig_state.expanded_routes.contains("/api"));
17722
17723        // Collapse virtual parent
17724        app.handle_action(Action::CollapseRow);
17725        assert!(!app.apig_state.expanded_routes.contains("/api"));
17726
17727        // Toggle expand again
17728        app.handle_action(Action::ExpandRow);
17729        assert!(app.apig_state.expanded_routes.contains("/api"));
17730    }
17731
17732    #[test]
17733    fn test_apig_routes_navigation() {
17734        use crate::apig::api::RestApi;
17735        use crate::apig::route::Route;
17736        use crate::ui::apig::ApiDetailTab;
17737
17738        let mut app = test_app();
17739        app.mode = Mode::Normal;
17740        app.service_selected = true;
17741        app.current_service = Service::ApiGatewayApis;
17742        app.apig_state.current_api = Some(RestApi {
17743            id: "test123".to_string(),
17744            name: "Test API".to_string(),
17745            description: "Test".to_string(),
17746            created_date: "2024-01-01".to_string(),
17747            api_key_source: "HEADER".to_string(),
17748            endpoint_configuration: "REGIONAL".to_string(),
17749            protocol_type: "HTTP".to_string(),
17750            disable_execute_api_endpoint: false,
17751            status: "AVAILABLE".to_string(),
17752        });
17753        app.apig_state.detail_tab = ApiDetailTab::Routes;
17754        app.apig_state.routes.items = vec![
17755            Route {
17756                route_id: "1".to_string(),
17757                route_key: "/api/users".to_string(),
17758                target: "integration1".to_string(),
17759                authorization_type: "NONE".to_string(),
17760                api_key_required: false,
17761                display_name: String::new(),
17762                arn: String::new(),
17763            },
17764            Route {
17765                route_id: "2".to_string(),
17766                route_key: "/health".to_string(),
17767                target: "integration2".to_string(),
17768                authorization_type: "NONE".to_string(),
17769                api_key_required: false,
17770                display_name: String::new(),
17771                arn: String::new(),
17772            },
17773        ];
17774
17775        // Start at first route
17776        assert_eq!(app.apig_state.routes.selected, 0);
17777
17778        // Navigate down
17779        app.handle_action(Action::NextItem);
17780        assert_eq!(app.apig_state.routes.selected, 1);
17781
17782        // Navigate down (should stay at last)
17783        app.handle_action(Action::NextItem);
17784        assert_eq!(app.apig_state.routes.selected, 1);
17785
17786        // Navigate up
17787        app.handle_action(Action::PrevItem);
17788        assert_eq!(app.apig_state.routes.selected, 0);
17789
17790        // Navigate up (should stay at first)
17791        app.handle_action(Action::PrevItem);
17792        assert_eq!(app.apig_state.routes.selected, 0);
17793    }
17794
17795    #[test]
17796    fn test_apig_routes_expand_jumps_to_child() {
17797        use crate::apig::api::RestApi;
17798        use crate::apig::route::Route;
17799        use crate::ui::apig::ApiDetailTab;
17800        use std::collections::HashMap;
17801
17802        let mut app = test_app();
17803        app.mode = Mode::Normal;
17804        app.service_selected = true;
17805        app.current_service = Service::ApiGatewayApis;
17806        app.apig_state.current_api = Some(RestApi {
17807            id: "test123".to_string(),
17808            name: "Test API".to_string(),
17809            description: "Test".to_string(),
17810            created_date: "2024-01-01".to_string(),
17811            api_key_source: "HEADER".to_string(),
17812            endpoint_configuration: "REGIONAL".to_string(),
17813            protocol_type: "HTTP".to_string(),
17814            disable_execute_api_endpoint: false,
17815            status: "AVAILABLE".to_string(),
17816        });
17817        app.apig_state.detail_tab = ApiDetailTab::Routes;
17818
17819        // Set up hierarchy: /api with child /api/users
17820        app.apig_state.routes.items = vec![Route {
17821            route_id: "virtual_/api".to_string(),
17822            route_key: "/api".to_string(),
17823            target: String::new(),
17824            authorization_type: String::new(),
17825            api_key_required: false,
17826            display_name: String::new(),
17827            arn: String::new(),
17828        }];
17829
17830        let mut children = HashMap::new();
17831        children.insert(
17832            "/api".to_string(),
17833            vec![Route {
17834                route_id: "1".to_string(),
17835                route_key: "/api/users".to_string(),
17836                target: "integration1".to_string(),
17837                authorization_type: "NONE".to_string(),
17838                api_key_required: false,
17839                display_name: String::new(),
17840                arn: String::new(),
17841            }],
17842        );
17843        app.apig_state.route_children = children;
17844
17845        // Start at /api (row 0)
17846        assert_eq!(app.apig_state.routes.selected, 0);
17847        assert!(!app.apig_state.expanded_routes.contains("/api"));
17848
17849        // First expand - should expand /api
17850        app.handle_action(Action::ExpandRow);
17851        assert!(app.apig_state.expanded_routes.contains("/api"));
17852        assert_eq!(app.apig_state.routes.selected, 0);
17853
17854        // Second expand - should jump to first child (row 1)
17855        app.handle_action(Action::ExpandRow);
17856        assert_eq!(app.apig_state.routes.selected, 1);
17857    }
17858
17859    #[test]
17860    fn test_apig_filter_only_when_focused() {
17861        use crate::apig::api::RestApi;
17862        use crate::ui::apig::ApiDetailTab;
17863
17864        let mut app = test_app();
17865        app.current_service = Service::ApiGatewayApis;
17866        app.apig_state.current_api = Some(RestApi {
17867            id: "test".to_string(),
17868            name: "Test API".to_string(),
17869            description: "Test".to_string(),
17870            created_date: "2024-01-01".to_string(),
17871            api_key_source: "HEADER".to_string(),
17872            endpoint_configuration: "REGIONAL".to_string(),
17873            protocol_type: "HTTP".to_string(),
17874            disable_execute_api_endpoint: false,
17875            status: "AVAILABLE".to_string(),
17876        });
17877        app.apig_state.detail_tab = ApiDetailTab::Routes;
17878        app.mode = Mode::FilterInput;
17879
17880        // When input_focus is NOT on Filter, typing should not update filter
17881        app.apig_state.input_focus = InputFocus::Pagination;
17882        app.handle_action(Action::FilterInput('x'));
17883        assert_eq!(app.apig_state.route_filter, "");
17884
17885        // When input_focus IS on Filter, typing should update filter
17886        app.apig_state.input_focus = InputFocus::Filter;
17887        app.handle_action(Action::FilterInput('x'));
17888        assert_eq!(app.apig_state.route_filter, "x");
17889    }
17890
17891    #[test]
17892    fn test_apig_routes_and_resources_use_same_render_function() {
17893        // Both routes and resources must call crate::ui::table::render_tree_table
17894        let source = include_str!("ui/apig.rs");
17895        let render_calls: Vec<_> = source
17896            .match_indices("crate::ui::table::render_tree_table")
17897            .collect();
17898
17899        // Should have exactly 2 calls: one for resources, one for routes
17900        assert_eq!(
17901            render_calls.len(),
17902            2,
17903            "Both routes and resources must use render_tree_table"
17904        );
17905    }
17906
17907    #[test]
17908    fn test_s3_uses_same_render_function() {
17909        // S3 objects must also call crate::ui::table::render_tree_table
17910        let source = include_str!("ui/s3.rs");
17911        let render_calls: Vec<_> = source
17912            .match_indices("crate::ui::table::render_tree_table")
17913            .collect();
17914
17915        // Should have at least 1 call for objects table
17916        assert!(!render_calls.is_empty(), "S3 must use render_tree_table");
17917    }
17918
17919    #[test]
17920    fn test_s3_page_down_skips_expanded_children() {
17921        // PageDown should land on top-level bucket rows, not expanded child rows.
17922        // With 3 buckets where bucket0 is expanded and has 20 children,
17923        // the visual layout is: row0=bucket0, rows1-20=children, row21=bucket1, row22=bucket2.
17924        // PageDown (10 buckets) from bucket0 → should land on bucket1 (only 3 buckets total).
17925        use crate::keymap::Action;
17926
17927        let mut app = test_app();
17928        app.current_service = Service::S3Buckets;
17929        app.service_selected = true;
17930        app.mode = Mode::Normal;
17931        app.s3_state.bucket_visible_rows = std::cell::Cell::new(30);
17932
17933        app.s3_state.buckets.items = (0..3)
17934            .map(|i| S3Bucket {
17935                name: format!("bucket{}", i),
17936                region: "us-east-1".to_string(),
17937                creation_date: String::new(),
17938            })
17939            .collect();
17940
17941        // Expand bucket0 with 20 children
17942        app.s3_state.expanded_prefixes.insert("bucket0".to_string());
17943        app.s3_state.bucket_preview.insert(
17944            "bucket0".to_string(),
17945            (0..20)
17946                .map(|i| crate::app::S3Object {
17947                    key: format!("key{}", i),
17948                    size: 0,
17949                    last_modified: String::new(),
17950                    storage_class: String::new(),
17951                    is_prefix: false,
17952                })
17953                .collect(),
17954        );
17955
17956        app.s3_state.selected_row = 0;
17957
17958        // PageDown: jump 10 top-level buckets, but only 3 exist → clamp to last (bucket2)
17959        app.handle_action(Action::PageDown);
17960        // selected_row should be on bucket2 (visual row 22), not row 10 (child of bucket0)
17961        let row = app.s3_state.selected_row;
17962        assert_eq!(
17963            row, 22,
17964            "PageDown must land on bucket2 (visual row 22), got {}",
17965            row
17966        );
17967
17968        // PageUp from bucket2 back to bucket0
17969        app.handle_action(Action::PageUp);
17970        assert_eq!(
17971            app.s3_state.selected_row, 0,
17972            "PageUp must return to bucket0"
17973        );
17974    }
17975
17976    #[test]
17977    fn test_s3_page_down_with_many_buckets_skips_expanded() {
17978        // With 25 buckets where bucket0 has 100 children expanded,
17979        // PageDown from bucket0 should land on bucket10, not on a child row.
17980        use crate::keymap::Action;
17981
17982        let mut app = test_app();
17983        app.current_service = Service::S3Buckets;
17984        app.service_selected = true;
17985        app.mode = Mode::Normal;
17986        app.s3_state.bucket_visible_rows = std::cell::Cell::new(30);
17987
17988        app.s3_state.buckets.items = (0..25)
17989            .map(|i| S3Bucket {
17990                name: format!("bucket{}", i),
17991                region: "us-east-1".to_string(),
17992                creation_date: String::new(),
17993            })
17994            .collect();
17995
17996        // Expand bucket0 with 100 children
17997        app.s3_state.expanded_prefixes.insert("bucket0".to_string());
17998        app.s3_state.bucket_preview.insert(
17999            "bucket0".to_string(),
18000            (0..100)
18001                .map(|i| crate::app::S3Object {
18002                    key: format!("key{}", i),
18003                    size: 0,
18004                    last_modified: String::new(),
18005                    storage_class: String::new(),
18006                    is_prefix: false,
18007                })
18008                .collect(),
18009        );
18010
18011        app.s3_state.selected_row = 0;
18012
18013        // PageDown: should jump to bucket10 (visual row 100 + 10 = 110)
18014        app.handle_action(Action::PageDown);
18015        let row = app.s3_state.selected_row;
18016        // bucket0 = row 0, 100 children = rows 1-100, bucket1 = row 101, ..., bucket10 = row 110
18017        assert_eq!(
18018            row, 110,
18019            "PageDown must land on bucket10 (row 110), got {}",
18020            row
18021        );
18022    }
18023
18024    #[test]
18025    fn test_search_icon_has_proper_border_spacing() {
18026        // SEARCH_ICON should have dashes on both sides for proper border rendering
18027        // Should be "─ 🔍 ─" not "─ 🔍 " to avoid "╭N" appearance
18028        use crate::ui::SEARCH_ICON;
18029
18030        assert!(SEARCH_ICON.starts_with("─"), "Should start with dash");
18031        assert!(
18032            SEARCH_ICON.ends_with("─"),
18033            "Should end with dash for proper border spacing"
18034        );
18035        assert!(SEARCH_ICON.contains("🔍"), "Should contain search icon");
18036    }
18037
18038    #[test]
18039    fn test_apig_expand_with_filter() {
18040        use crate::apig::api::RestApi;
18041        use crate::apig::route::Route;
18042        use crate::ui::apig::ApiDetailTab;
18043        use std::collections::HashMap;
18044
18045        let mut app = test_app();
18046        app.current_service = Service::ApiGatewayApis;
18047        app.apig_state.current_api = Some(RestApi {
18048            id: "test123".to_string(),
18049            name: "Test API".to_string(),
18050            description: "Test".to_string(),
18051            created_date: "2024-01-01".to_string(),
18052            api_key_source: "HEADER".to_string(),
18053            endpoint_configuration: "REGIONAL".to_string(),
18054            protocol_type: "HTTP".to_string(),
18055            disable_execute_api_endpoint: false,
18056            status: "AVAILABLE".to_string(),
18057        });
18058        app.apig_state.detail_tab = ApiDetailTab::Routes;
18059
18060        // Setup routes with parent and child
18061        app.apig_state.routes.items = vec![Route {
18062            route_id: "0".to_string(),
18063            route_key: "/api".to_string(),
18064            target: "".to_string(),
18065            authorization_type: "NONE".to_string(),
18066            api_key_required: false,
18067            display_name: "/api".to_string(),
18068            arn: String::new(),
18069        }];
18070
18071        let mut children = HashMap::new();
18072        children.insert(
18073            "/api".to_string(),
18074            vec![Route {
18075                route_id: "1".to_string(),
18076                route_key: "GET".to_string(),
18077                target: "integration1".to_string(),
18078                authorization_type: "NONE".to_string(),
18079                api_key_required: false,
18080                display_name: "GET".to_string(),
18081                arn: String::new(),
18082            }],
18083        );
18084        app.apig_state.route_children = children;
18085
18086        // Apply filter that matches child
18087        app.apig_state.route_filter = "GET".to_string();
18088
18089        // Parent should be visible (because child matches)
18090        // Expand should work on filtered parent
18091        assert_eq!(app.apig_state.routes.selected, 0);
18092        assert!(!app.apig_state.expanded_routes.contains("/api"));
18093
18094        app.handle_action(Action::ExpandRow);
18095
18096        // Should expand the parent
18097        assert!(app.apig_state.expanded_routes.contains("/api"));
18098    }
18099
18100    #[test]
18101    fn test_apig_console_url_routes() {
18102        use crate::apig::api::RestApi;
18103        use crate::apig::route::Route;
18104        use crate::ui::apig::ApiDetailTab;
18105
18106        let mut app = test_app();
18107        app.current_service = Service::ApiGatewayApis;
18108        app.config.region = "us-east-1".to_string();
18109
18110        // Test API list URL
18111        let url = app.get_console_url();
18112        assert!(url.contains("apigateway/main/apis"));
18113        assert!(url.contains("region=us-east-1"));
18114
18115        // Test routes URL with selection
18116        app.apig_state.current_api = Some(RestApi {
18117            id: "2todvod3n0".to_string(),
18118            name: "Test API".to_string(),
18119            description: "Test".to_string(),
18120            created_date: "2024-01-01".to_string(),
18121            api_key_source: "HEADER".to_string(),
18122            endpoint_configuration: "REGIONAL".to_string(),
18123            protocol_type: "HTTP".to_string(),
18124            disable_execute_api_endpoint: false,
18125            status: "AVAILABLE".to_string(),
18126        });
18127        app.apig_state.detail_tab = ApiDetailTab::Routes;
18128        app.apig_state.routes.items = vec![Route {
18129            route_id: "eizmisr".to_string(),
18130            route_key: "GET /test".to_string(),
18131            target: "integration1".to_string(),
18132            authorization_type: "NONE".to_string(),
18133            api_key_required: false,
18134            display_name: "GET /test".to_string(),
18135            arn: String::new(),
18136        }];
18137        app.apig_state.routes.selected = 0;
18138
18139        let url = app.get_console_url();
18140        assert!(url.contains("apigateway/main/develop/routes"));
18141        assert!(url.contains("api=2todvod3n0"));
18142        assert!(url.contains("routes=eizmisr"));
18143        assert!(url.contains("region=us-east-1"));
18144    }
18145
18146    #[test]
18147    fn test_apig_console_url_resources() {
18148        use crate::apig::api::RestApi;
18149        use crate::apig::resource::Resource;
18150        use crate::ui::apig::ApiDetailTab;
18151
18152        let mut app = test_app();
18153        app.current_service = Service::ApiGatewayApis;
18154        app.config.region = "us-east-1".to_string();
18155
18156        // Test resources URL with selection
18157        app.apig_state.current_api = Some(RestApi {
18158            id: "2j9j50ze47".to_string(),
18159            name: "Test API".to_string(),
18160            description: "Test".to_string(),
18161            created_date: "2024-01-01".to_string(),
18162            api_key_source: "HEADER".to_string(),
18163            endpoint_configuration: "REGIONAL".to_string(),
18164            protocol_type: "REST".to_string(),
18165            disable_execute_api_endpoint: false,
18166            status: "AVAILABLE".to_string(),
18167        });
18168        app.apig_state.detail_tab = ApiDetailTab::Routes;
18169        app.apig_state.resources.items = vec![Resource {
18170            id: "abc123".to_string(),
18171            path: "/test".to_string(),
18172            parent_id: None,
18173            methods: vec![],
18174            display_name: "/test".to_string(),
18175            arn: String::new(),
18176        }];
18177        app.apig_state.resources.selected = 0;
18178
18179        let url = app.get_console_url();
18180        assert!(url.contains("apigateway/main/apis/2j9j50ze47/resources"));
18181        assert!(url.contains("api=2j9j50ze47"));
18182        assert!(url.contains("#abc123"));
18183        assert!(url.contains("region=us-east-1"));
18184    }
18185
18186    #[test]
18187    fn test_apig_console_url_routes_parent_vs_leaf() {
18188        use crate::apig::api::RestApi;
18189        use crate::apig::route::Route;
18190        use crate::ui::apig::ApiDetailTab;
18191        use std::collections::HashMap;
18192
18193        let mut app = test_app();
18194        app.current_service = Service::ApiGatewayApis;
18195        app.config.region = "us-east-1".to_string();
18196
18197        app.apig_state.current_api = Some(RestApi {
18198            id: "2todvod3n0".to_string(),
18199            name: "Test API".to_string(),
18200            description: "Test".to_string(),
18201            created_date: "2024-01-01".to_string(),
18202            api_key_source: "HEADER".to_string(),
18203            endpoint_configuration: "REGIONAL".to_string(),
18204            protocol_type: "HTTP".to_string(),
18205            disable_execute_api_endpoint: false,
18206            status: "AVAILABLE".to_string(),
18207        });
18208        app.apig_state.detail_tab = ApiDetailTab::Routes;
18209
18210        // Parent node (virtual path, no target)
18211        app.apig_state.routes.items = vec![Route {
18212            route_id: "parent".to_string(),
18213            route_key: "/v1/get/jobs".to_string(),
18214            target: "".to_string(), // Empty target = parent node
18215            authorization_type: "NONE".to_string(),
18216            api_key_required: false,
18217            display_name: "/v1/get/jobs".to_string(),
18218            arn: String::new(),
18219        }];
18220
18221        // Child leaf node (actual route with target)
18222        let mut children = HashMap::new();
18223        children.insert(
18224            "/v1/get/jobs".to_string(),
18225            vec![Route {
18226                route_id: "1iz9vtl".to_string(),
18227                route_key: "GET".to_string(),
18228                target: "integration1".to_string(), // Has target = leaf node
18229                authorization_type: "NONE".to_string(),
18230                api_key_required: false,
18231                display_name: "GET".to_string(),
18232                arn: String::new(),
18233            }],
18234        );
18235        app.apig_state.route_children = children;
18236
18237        // Select parent - should NOT include routes parameter
18238        app.apig_state.routes.selected = 0;
18239        let url = app.get_console_url();
18240        assert!(url.contains("apigateway/main/develop/routes"));
18241        assert!(url.contains("api=2todvod3n0"));
18242        assert!(
18243            !url.contains("routes="),
18244            "Parent node should not include routes parameter"
18245        );
18246
18247        // Expand parent and select child leaf
18248        app.apig_state
18249            .expanded_routes
18250            .insert("/v1/get/jobs".to_string());
18251        app.apig_state.routes.selected = 1;
18252        let url = app.get_console_url();
18253        assert!(
18254            url.contains("routes=1iz9vtl"),
18255            "Leaf node should include routes parameter: {}",
18256            url
18257        );
18258    }
18259
18260    #[test]
18261    fn test_apig_preferences_context() {
18262        use crate::apig::api::RestApi;
18263        use crate::ui::apig::ApiDetailTab;
18264
18265        let mut app = test_app();
18266        app.current_service = Service::ApiGatewayApis;
18267
18268        // In API list view - current_api is None
18269        assert!(
18270            app.apig_state.current_api.is_none(),
18271            "Should be in list view"
18272        );
18273
18274        // In detail view - current_api is Some
18275        app.apig_state.current_api = Some(RestApi {
18276            id: "test123".to_string(),
18277            name: "Test API".to_string(),
18278            description: "Test".to_string(),
18279            created_date: "2024-01-01".to_string(),
18280            api_key_source: "HEADER".to_string(),
18281            endpoint_configuration: "REGIONAL".to_string(),
18282            protocol_type: "HTTP".to_string(),
18283            disable_execute_api_endpoint: false,
18284            status: "AVAILABLE".to_string(),
18285        });
18286        app.apig_state.detail_tab = ApiDetailTab::Routes;
18287
18288        assert!(
18289            app.apig_state.current_api.is_some(),
18290            "Should be in detail view"
18291        );
18292        // Preferences rendering will check current_api.is_none() to decide what to show
18293    }
18294
18295    #[test]
18296    fn test_apig_route_columns() {
18297        use crate::apig::route::Column as RouteColumn;
18298
18299        // Verify all columns exist
18300        let cols = RouteColumn::all();
18301        assert_eq!(cols.len(), 5);
18302
18303        // Verify column IDs
18304        assert_eq!(RouteColumn::RouteKey.id(), "route_key");
18305        assert_eq!(RouteColumn::RouteId.id(), "route_id");
18306        assert_eq!(RouteColumn::Arn.id(), "arn");
18307        assert_eq!(RouteColumn::AuthorizationType.id(), "authorization_type");
18308        assert_eq!(RouteColumn::Target.id(), "target");
18309
18310        // Verify from_id
18311        assert_eq!(
18312            RouteColumn::from_id("route_key"),
18313            Some(RouteColumn::RouteKey)
18314        );
18315        assert_eq!(RouteColumn::from_id("arn"), Some(RouteColumn::Arn));
18316        assert_eq!(RouteColumn::from_id("invalid"), None);
18317    }
18318
18319    #[test]
18320    fn test_apig_yank_copies_route_arn() {
18321        use crate::apig::api::RestApi;
18322        use crate::apig::route::Route;
18323        use crate::ui::apig::ApiDetailTab;
18324
18325        let mut app = test_app();
18326        app.current_service = Service::ApiGatewayApis;
18327        app.apig_state.current_api = Some(RestApi {
18328            id: "test123".to_string(),
18329            name: "Test API".to_string(),
18330            description: "Test".to_string(),
18331            created_date: "2024-01-01".to_string(),
18332            api_key_source: "HEADER".to_string(),
18333            endpoint_configuration: "REGIONAL".to_string(),
18334            protocol_type: "HTTP".to_string(),
18335            disable_execute_api_endpoint: false,
18336            status: "AVAILABLE".to_string(),
18337        });
18338        app.apig_state.detail_tab = ApiDetailTab::Routes;
18339
18340        // Add routes with ARNs
18341        app.apig_state.routes.items = vec![
18342            Route {
18343                route_id: "route1".to_string(),
18344                route_key: "GET /users".to_string(),
18345                target: "integrations/abc".to_string(),
18346                authorization_type: "NONE".to_string(),
18347                api_key_required: false,
18348                display_name: "GET /users".to_string(),
18349                arn: "arn:aws:apigateway:us-east-1::/apis/test123/routes/route1".to_string(),
18350            },
18351            Route {
18352                route_id: "route2".to_string(),
18353                route_key: "POST /users".to_string(),
18354                target: "integrations/def".to_string(),
18355                authorization_type: "AWS_IAM".to_string(),
18356                api_key_required: true,
18357                display_name: "POST /users".to_string(),
18358                arn: "arn:aws:apigateway:us-east-1::/apis/test123/routes/route2".to_string(),
18359            },
18360        ];
18361
18362        // Select first route
18363        app.apig_state.routes.selected = 0;
18364
18365        // Yank should copy ARN (we can't test clipboard directly, but we can verify the logic path)
18366        assert_eq!(
18367            app.apig_state.routes.items[0].arn,
18368            "arn:aws:apigateway:us-east-1::/apis/test123/routes/route1"
18369        );
18370    }
18371
18372    #[test]
18373    fn test_apig_yank_ignores_empty_arn() {
18374        use crate::apig::api::RestApi;
18375        use crate::apig::route::Route;
18376        use crate::ui::apig::ApiDetailTab;
18377
18378        let mut app = test_app();
18379        app.current_service = Service::ApiGatewayApis;
18380        app.apig_state.current_api = Some(RestApi {
18381            id: "test123".to_string(),
18382            name: "Test API".to_string(),
18383            description: "Test".to_string(),
18384            created_date: "2024-01-01".to_string(),
18385            api_key_source: "HEADER".to_string(),
18386            endpoint_configuration: "REGIONAL".to_string(),
18387            protocol_type: "HTTP".to_string(),
18388            disable_execute_api_endpoint: false,
18389            status: "AVAILABLE".to_string(),
18390        });
18391        app.apig_state.detail_tab = ApiDetailTab::Routes;
18392
18393        // Add virtual parent node with empty ARN
18394        app.apig_state.routes.items = vec![Route {
18395            route_id: String::new(),
18396            route_key: "/users".to_string(),
18397            target: String::new(), // Virtual parent
18398            authorization_type: String::new(),
18399            api_key_required: false,
18400            display_name: "/users".to_string(),
18401            arn: String::new(), // Empty ARN
18402        }];
18403
18404        app.apig_state.routes.selected = 0;
18405
18406        // Verify empty ARN won't be copied
18407        assert!(
18408            app.apig_state.routes.items[0].arn.is_empty(),
18409            "Virtual parent should have empty ARN"
18410        );
18411    }
18412
18413    #[test]
18414    fn test_apig_route_column_toggle() {
18415        use crate::apig::api::RestApi;
18416        use crate::apig::route::Column as RouteColumn;
18417        use crate::keymap::Action;
18418        use crate::ui::apig::ApiDetailTab;
18419
18420        let mut app = test_app();
18421        app.current_service = Service::ApiGatewayApis;
18422        app.mode = Mode::ColumnSelector;
18423        app.apig_state.current_api = Some(RestApi {
18424            id: "test123".to_string(),
18425            name: "Test API".to_string(),
18426            description: "Test".to_string(),
18427            created_date: "2024-01-01".to_string(),
18428            api_key_source: "HEADER".to_string(),
18429            endpoint_configuration: "REGIONAL".to_string(),
18430            protocol_type: "HTTP".to_string(),
18431            disable_execute_api_endpoint: false,
18432            status: "AVAILABLE".to_string(),
18433        });
18434        app.apig_state.detail_tab = ApiDetailTab::Routes;
18435
18436        // Initially all columns visible
18437        assert_eq!(app.apig_route_visible_column_ids.len(), 5);
18438
18439        // Try to toggle first column (Route) - should be locked, no effect
18440        app.column_selector_index = 1;
18441        app.handle_action(Action::ToggleColumn);
18442        assert_eq!(app.apig_route_visible_column_ids.len(), 5);
18443        assert!(app
18444            .apig_route_visible_column_ids
18445            .contains(&RouteColumn::RouteKey.id()));
18446
18447        // Select ARN column (index 3 in UI)
18448        app.column_selector_index = 3;
18449        app.handle_action(Action::ToggleColumn);
18450
18451        // ARN should be removed
18452        assert_eq!(app.apig_route_visible_column_ids.len(), 4);
18453        assert!(!app
18454            .apig_route_visible_column_ids
18455            .contains(&RouteColumn::Arn.id()));
18456
18457        // Toggle again to add it back
18458        app.handle_action(Action::ToggleColumn);
18459        assert_eq!(app.apig_route_visible_column_ids.len(), 5);
18460        assert!(app
18461            .apig_route_visible_column_ids
18462            .contains(&RouteColumn::Arn.id()));
18463    }
18464
18465    #[test]
18466    fn test_apig_resource_column_toggle() {
18467        use crate::apig::api::RestApi;
18468        use crate::apig::resource::Column as ResourceColumn;
18469        use crate::keymap::Action;
18470        use crate::ui::apig::ApiDetailTab;
18471
18472        let mut app = test_app();
18473        app.current_service = Service::ApiGatewayApis;
18474        app.mode = Mode::ColumnSelector;
18475        app.apig_state.current_api = Some(RestApi {
18476            id: "test123".to_string(),
18477            name: "Test API".to_string(),
18478            description: "Test".to_string(),
18479            created_date: "2024-01-01".to_string(),
18480            api_key_source: "HEADER".to_string(),
18481            endpoint_configuration: "REGIONAL".to_string(),
18482            protocol_type: "REST".to_string(), // REST API shows resources
18483            disable_execute_api_endpoint: false,
18484            status: "AVAILABLE".to_string(),
18485        });
18486        app.apig_state.detail_tab = ApiDetailTab::Routes; // Resources shown in Routes tab for REST APIs
18487
18488        // Initially all columns visible
18489        assert_eq!(app.apig_resource_visible_column_ids.len(), 3);
18490
18491        // Try to toggle first column (Resource) - should be locked, no effect
18492        app.column_selector_index = 1;
18493        app.handle_action(Action::ToggleColumn);
18494        assert_eq!(app.apig_resource_visible_column_ids.len(), 3);
18495        assert!(app
18496            .apig_resource_visible_column_ids
18497            .contains(&ResourceColumn::Path.id()));
18498
18499        // Select ARN column (index 3 in UI)
18500        app.column_selector_index = 3;
18501        app.handle_action(Action::ToggleColumn);
18502
18503        // ARN should be removed
18504        assert_eq!(app.apig_resource_visible_column_ids.len(), 2);
18505        assert!(!app
18506            .apig_resource_visible_column_ids
18507            .contains(&ResourceColumn::Arn.id()));
18508
18509        // Toggle again to add it back
18510        app.handle_action(Action::ToggleColumn);
18511        assert_eq!(app.apig_resource_visible_column_ids.len(), 3);
18512        assert!(app
18513            .apig_resource_visible_column_ids
18514            .contains(&ResourceColumn::Arn.id()));
18515    }
18516
18517    #[test]
18518    fn test_cloudtrail_filter_input() {
18519        let mut app = App::new_without_client("default".to_string(), None);
18520        app.service_selected = true;
18521        app.current_service = Service::CloudTrailEvents;
18522
18523        app.handle_action(Action::StartFilter);
18524        assert_eq!(app.mode, Mode::FilterInput);
18525
18526        app.cloudtrail_state.table.filter = "test".to_string();
18527
18528        assert_eq!(app.cloudtrail_state.table.filter, "test");
18529    }
18530
18531    #[test]
18532    fn test_cloudtrail_row_expansion() {
18533        let mut app = App::new_without_client("default".to_string(), None);
18534        app.service_selected = true;
18535        app.current_service = Service::CloudTrailEvents;
18536        app.cloudtrail_state.table.items = vec![CloudTrailEvent {
18537            event_name: "Event1".to_string(),
18538            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
18539            username: "user1".to_string(),
18540            event_source: "s3.amazonaws.com".to_string(),
18541            resource_type: "Bucket".to_string(),
18542            resource_name: "my-bucket".to_string(),
18543            read_only: "false".to_string(),
18544            aws_region: "us-east-1".to_string(),
18545            event_id: "abc123".to_string(),
18546            access_key_id: "AKIA...".to_string(),
18547            source_ip_address: "1.2.3.4".to_string(),
18548            error_code: "".to_string(),
18549            request_id: "req-123".to_string(),
18550            event_type: "AwsApiCall".to_string(),
18551            cloud_trail_event_json: "{}".to_string(),
18552        }];
18553
18554        assert_eq!(app.cloudtrail_state.table.expanded_item, None);
18555
18556        app.expand_row();
18557        assert_eq!(app.cloudtrail_state.table.expanded_item, Some(0));
18558
18559        app.collapse_row();
18560        assert_eq!(app.cloudtrail_state.table.expanded_item, None);
18561    }
18562
18563    #[test]
18564    fn test_cloudtrail_service_initialization() {
18565        let app = App::new_without_client("default".to_string(), None);
18566        assert_eq!(app.cloudtrail_event_column_ids.len(), 14);
18567        assert_eq!(app.cloudtrail_event_visible_column_ids.len(), 6);
18568    }
18569
18570    #[test]
18571    fn test_cloudtrail_service_name() {
18572        assert_eq!(
18573            Service::CloudTrailEvents.name(),
18574            "CloudTrail › Event History"
18575        );
18576    }
18577
18578    #[test]
18579    fn test_cloudtrail_in_service_picker() {
18580        let app = App::new_without_client("default".to_string(), None);
18581        assert!(app
18582            .service_picker
18583            .services
18584            .contains(&"CloudTrail › Event History"));
18585    }
18586
18587    #[test]
18588    fn test_cloudtrail_service_selection() {
18589        let mut app = App::new_without_client("default".to_string(), None);
18590        app.current_service = Service::CloudTrailEvents;
18591        app.service_selected = true;
18592
18593        assert_eq!(app.current_service, Service::CloudTrailEvents);
18594        assert!(app.service_selected);
18595    }
18596
18597    #[test]
18598    fn test_cloudtrail_filter_resets_selection() {
18599        let mut app = App::new_without_client("default".to_string(), None);
18600        app.service_selected = true;
18601        app.current_service = Service::CloudTrailEvents;
18602        app.cloudtrail_state.table.selected = 5;
18603        app.cloudtrail_state.table.expanded_item = Some(3);
18604
18605        app.handle_action(Action::StartFilter);
18606        app.apply_filter_operation(|_| {});
18607
18608        assert_eq!(app.cloudtrail_state.table.selected, 0);
18609        assert_eq!(app.cloudtrail_state.table.expanded_item, None);
18610    }
18611
18612    #[test]
18613    fn test_cloudtrail_column_toggle() {
18614        let mut app = App::new_without_client("default".to_string(), None);
18615        app.service_selected = true;
18616        app.current_service = Service::CloudTrailEvents;
18617        app.mode = Mode::ColumnSelector;
18618
18619        // Initially 6 columns visible
18620        assert_eq!(app.cloudtrail_event_visible_column_ids.len(), 6);
18621
18622        // Toggle column at index 7 (ReadOnly - 7th column, index 1-14)
18623        app.column_selector_index = 7;
18624        app.handle_action(Action::ToggleColumn);
18625
18626        // Should now have 7 visible columns
18627        assert_eq!(app.cloudtrail_event_visible_column_ids.len(), 7);
18628    }
18629
18630    #[test]
18631    fn test_cloudtrail_tab_cycles_filter_focus() {
18632        let mut app = App::new_without_client("default".to_string(), None);
18633        app.service_selected = true;
18634        app.current_service = Service::CloudTrailEvents;
18635        app.mode = Mode::FilterInput;
18636        app.cloudtrail_state.input_focus = InputFocus::Filter;
18637
18638        // Tab should cycle to Pagination
18639        app.handle_action(Action::NextFilterFocus);
18640        assert_eq!(app.cloudtrail_state.input_focus, InputFocus::Pagination);
18641
18642        // Tab again should cycle back to Filter
18643        app.handle_action(Action::NextFilterFocus);
18644        assert_eq!(app.cloudtrail_state.input_focus, InputFocus::Filter);
18645    }
18646
18647    #[test]
18648    fn test_cloudtrail_shift_tab_cycles_filter_focus() {
18649        let mut app = App::new_without_client("default".to_string(), None);
18650        app.service_selected = true;
18651        app.current_service = Service::CloudTrailEvents;
18652        app.mode = Mode::FilterInput;
18653        app.cloudtrail_state.input_focus = InputFocus::Filter;
18654
18655        // Shift+Tab should cycle to Pagination
18656        app.handle_action(Action::PrevFilterFocus);
18657        assert_eq!(app.cloudtrail_state.input_focus, InputFocus::Pagination);
18658
18659        // Shift+Tab again should cycle back to Filter
18660        app.handle_action(Action::PrevFilterFocus);
18661        assert_eq!(app.cloudtrail_state.input_focus, InputFocus::Filter);
18662    }
18663
18664    #[test]
18665    fn test_cloudtrail_detail_view_tab_cycles_focus() {
18666        let mut app = App::new_without_client("default".to_string(), None);
18667        app.service_selected = true;
18668        app.current_service = Service::CloudTrailEvents;
18669        app.mode = Mode::Normal;
18670        app.cloudtrail_state.current_event = Some(CloudTrailEvent {
18671            event_name: "PutObject".to_string(),
18672            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
18673            username: "user".to_string(),
18674            event_source: "s3.amazonaws.com".to_string(),
18675            resource_type: "AWS::S3::Bucket".to_string(),
18676            resource_name: "my-bucket".to_string(),
18677            read_only: "false".to_string(),
18678            aws_region: "us-east-1".to_string(),
18679            event_id: "90c72977-31e0-4079-9a74-ee25e5d7aadf".to_string(),
18680            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
18681            source_ip_address: "192.0.2.1".to_string(),
18682            error_code: "".to_string(),
18683            request_id: "req-123".to_string(),
18684            event_type: "AwsApiCall".to_string(),
18685            cloud_trail_event_json: r#"{"eventName":"PutObject"}"#.to_string(),
18686        });
18687
18688        // Default focus is Resources
18689        assert_eq!(
18690            app.cloudtrail_state.detail_focus,
18691            CloudTrailDetailFocus::Resources
18692        );
18693
18694        // Tab cycles to EventRecord
18695        app.handle_action(Action::NextDetailTab);
18696        assert_eq!(
18697            app.cloudtrail_state.detail_focus,
18698            CloudTrailDetailFocus::EventRecord
18699        );
18700
18701        // Tab cycles back to Resources
18702        app.handle_action(Action::NextDetailTab);
18703        assert_eq!(
18704            app.cloudtrail_state.detail_focus,
18705            CloudTrailDetailFocus::Resources
18706        );
18707    }
18708
18709    #[test]
18710    fn test_cloudtrail_detail_view_shift_tab_cycles_focus() {
18711        let mut app = App::new_without_client("default".to_string(), None);
18712        app.service_selected = true;
18713        app.current_service = Service::CloudTrailEvents;
18714        app.mode = Mode::Normal;
18715        app.cloudtrail_state.current_event = Some(CloudTrailEvent {
18716            event_name: "PutObject".to_string(),
18717            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
18718            username: "user".to_string(),
18719            event_source: "s3.amazonaws.com".to_string(),
18720            resource_type: "AWS::S3::Bucket".to_string(),
18721            resource_name: "my-bucket".to_string(),
18722            read_only: "false".to_string(),
18723            aws_region: "us-east-1".to_string(),
18724            event_id: "90c72977-31e0-4079-9a74-ee25e5d7aadf".to_string(),
18725            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
18726            source_ip_address: "192.0.2.1".to_string(),
18727            error_code: "".to_string(),
18728            request_id: "req-123".to_string(),
18729            event_type: "AwsApiCall".to_string(),
18730            cloud_trail_event_json: r#"{"eventName":"PutObject"}"#.to_string(),
18731        });
18732
18733        // Default focus is Resources
18734        assert_eq!(
18735            app.cloudtrail_state.detail_focus,
18736            CloudTrailDetailFocus::Resources
18737        );
18738
18739        // Shift+Tab cycles to EventRecord
18740        app.handle_action(Action::PrevDetailTab);
18741        assert_eq!(
18742            app.cloudtrail_state.detail_focus,
18743            CloudTrailDetailFocus::EventRecord
18744        );
18745
18746        // Shift+Tab cycles back to Resources
18747        app.handle_action(Action::PrevDetailTab);
18748        assert_eq!(
18749            app.cloudtrail_state.detail_focus,
18750            CloudTrailDetailFocus::Resources
18751        );
18752    }
18753
18754    #[test]
18755    fn test_cloudtrail_json_scroll_with_arrow_keys() {
18756        let mut app = App::new_without_client("default".to_string(), None);
18757        app.service_selected = true;
18758        app.current_service = Service::CloudTrailEvents;
18759        app.mode = Mode::Normal;
18760        app.cloudtrail_state.current_event = Some(CloudTrailEvent {
18761            event_name: "PutObject".to_string(),
18762            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
18763            username: "user".to_string(),
18764            event_source: "s3.amazonaws.com".to_string(),
18765            resource_type: "AWS::S3::Bucket".to_string(),
18766            resource_name: "my-bucket".to_string(),
18767            read_only: "false".to_string(),
18768            aws_region: "us-east-1".to_string(),
18769            event_id: "90c72977-31e0-4079-9a74-ee25e5d7aadf".to_string(),
18770            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
18771            source_ip_address: "192.0.2.1".to_string(),
18772            error_code: "".to_string(),
18773            request_id: "req-123".to_string(),
18774            event_type: "AwsApiCall".to_string(),
18775            cloud_trail_event_json: (0..50)
18776                .map(|i| format!("line {}", i))
18777                .collect::<Vec<_>>()
18778                .join("\n"),
18779        });
18780        app.cloudtrail_state.detail_focus = CloudTrailDetailFocus::EventRecord;
18781        app.cloudtrail_state.event_json_scroll = 0;
18782
18783        // Down arrow scrolls down
18784        app.handle_action(Action::NextItem);
18785        assert_eq!(app.cloudtrail_state.event_json_scroll, 1);
18786
18787        app.handle_action(Action::NextItem);
18788        assert_eq!(app.cloudtrail_state.event_json_scroll, 2);
18789
18790        // Up arrow scrolls up
18791        app.handle_action(Action::PrevItem);
18792        assert_eq!(app.cloudtrail_state.event_json_scroll, 1);
18793
18794        app.handle_action(Action::PrevItem);
18795        assert_eq!(app.cloudtrail_state.event_json_scroll, 0);
18796
18797        // Up at 0 stays at 0
18798        app.handle_action(Action::PrevItem);
18799        assert_eq!(app.cloudtrail_state.event_json_scroll, 0);
18800    }
18801
18802    #[test]
18803    fn test_cloudtrail_tab_works_with_no_resources() {
18804        let mut app = App::new_without_client("default".to_string(), None);
18805        app.service_selected = true;
18806        app.current_service = Service::CloudTrailEvents;
18807        app.mode = Mode::Normal;
18808        app.cloudtrail_state.current_event = Some(CloudTrailEvent {
18809            event_name: "PutObject".to_string(),
18810            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
18811            username: "user".to_string(),
18812            event_source: "s3.amazonaws.com".to_string(),
18813            resource_type: "".to_string(), // No resource
18814            resource_name: "".to_string(), // No resource
18815            read_only: "false".to_string(),
18816            aws_region: "us-east-1".to_string(),
18817            event_id: "90c72977-31e0-4079-9a74-ee25e5d7aadf".to_string(),
18818            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
18819            source_ip_address: "192.0.2.1".to_string(),
18820            error_code: "".to_string(),
18821            request_id: "req-123".to_string(),
18822            event_type: "AwsApiCall".to_string(),
18823            cloud_trail_event_json: r#"{"eventName":"PutObject"}"#.to_string(),
18824        });
18825        app.cloudtrail_state.detail_focus = CloudTrailDetailFocus::EventRecord;
18826
18827        // Tab cycles to Resources even with no resources
18828        app.handle_action(Action::NextDetailTab);
18829        assert_eq!(
18830            app.cloudtrail_state.detail_focus,
18831            CloudTrailDetailFocus::Resources
18832        );
18833
18834        // Tab cycles back to EventRecord
18835        app.handle_action(Action::NextDetailTab);
18836        assert_eq!(
18837            app.cloudtrail_state.detail_focus,
18838            CloudTrailDetailFocus::EventRecord
18839        );
18840    }
18841
18842    #[test]
18843    fn test_cloudtrail_resources_expand_collapse() {
18844        let mut app = App::new_without_client("default".to_string(), None);
18845        app.service_selected = true;
18846        app.current_service = Service::CloudTrailEvents;
18847        app.mode = Mode::Normal;
18848        app.cloudtrail_state.current_event = Some(CloudTrailEvent {
18849            event_name: "PutObject".to_string(),
18850            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
18851            username: "user".to_string(),
18852            event_source: "s3.amazonaws.com".to_string(),
18853            resource_type: "AWS::S3::Bucket".to_string(),
18854            resource_name: "my-bucket".to_string(),
18855            read_only: "false".to_string(),
18856            aws_region: "us-east-1".to_string(),
18857            event_id: "90c72977-31e0-4079-9a74-ee25e5d7aadf".to_string(),
18858            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
18859            source_ip_address: "192.0.2.1".to_string(),
18860            error_code: "".to_string(),
18861            request_id: "req-123".to_string(),
18862            event_type: "AwsApiCall".to_string(),
18863            cloud_trail_event_json: r#"{"eventName":"PutObject"}"#.to_string(),
18864        });
18865        app.cloudtrail_state.detail_focus = CloudTrailDetailFocus::Resources;
18866        app.cloudtrail_state.resources_expanded_index = None;
18867
18868        // Right arrow expands
18869        app.handle_action(Action::ExpandRow);
18870        assert_eq!(app.cloudtrail_state.resources_expanded_index, Some(0));
18871
18872        // Left arrow collapses
18873        app.handle_action(Action::CollapseRow);
18874        assert_eq!(app.cloudtrail_state.resources_expanded_index, None);
18875    }
18876
18877    #[test]
18878    fn test_cloudtrail_event_json_no_column_selector() {
18879        let mut app = App::new_without_client("default".to_string(), None);
18880        app.service_selected = true;
18881        app.current_service = Service::CloudTrailEvents;
18882        app.mode = Mode::Normal;
18883        app.cloudtrail_state.current_event = Some(CloudTrailEvent {
18884            event_name: "PutObject".to_string(),
18885            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
18886            username: "user".to_string(),
18887            event_source: "s3.amazonaws.com".to_string(),
18888            resource_type: "AWS::S3::Bucket".to_string(),
18889            resource_name: "my-bucket".to_string(),
18890            read_only: "false".to_string(),
18891            aws_region: "us-east-1".to_string(),
18892            event_id: "90c72977-31e0-4079-9a74-ee25e5d7aadf".to_string(),
18893            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
18894            source_ip_address: "192.0.2.1".to_string(),
18895            error_code: "".to_string(),
18896            request_id: "req-123".to_string(),
18897            event_type: "AwsApiCall".to_string(),
18898            cloud_trail_event_json: r#"{"eventName":"PutObject"}"#.to_string(),
18899        });
18900        app.cloudtrail_state.detail_focus = CloudTrailDetailFocus::EventRecord;
18901
18902        // 'p' should not open column selector on Event JSON
18903        app.handle_action(Action::OpenColumnSelector);
18904        assert_eq!(app.mode, Mode::Normal);
18905
18906        // But should work on Resources
18907        app.cloudtrail_state.detail_focus = CloudTrailDetailFocus::Resources;
18908        app.handle_action(Action::OpenColumnSelector);
18909        assert_eq!(app.mode, Mode::ColumnSelector);
18910    }
18911
18912    #[test]
18913    fn test_cloudtrail_resources_preferences_show_only_3_columns() {
18914        let mut app = App::new_without_client("default".to_string(), None);
18915        app.service_selected = true;
18916        app.current_service = Service::CloudTrailEvents;
18917        app.mode = Mode::ColumnSelector;
18918        app.cloudtrail_state.current_event = Some(CloudTrailEvent {
18919            event_name: "PutObject".to_string(),
18920            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
18921            username: "user".to_string(),
18922            event_source: "s3.amazonaws.com".to_string(),
18923            resource_type: "AWS::S3::Bucket".to_string(),
18924            resource_name: "my-bucket".to_string(),
18925            read_only: "false".to_string(),
18926            aws_region: "us-east-1".to_string(),
18927            event_id: "90c72977-31e0-4079-9a74-ee25e5d7aadf".to_string(),
18928            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
18929            source_ip_address: "192.0.2.1".to_string(),
18930            error_code: "".to_string(),
18931            request_id: "req-123".to_string(),
18932            event_type: "AwsApiCall".to_string(),
18933            cloud_trail_event_json: r#"{"eventName":"PutObject"}"#.to_string(),
18934        });
18935        app.cloudtrail_state.detail_focus = CloudTrailDetailFocus::Resources;
18936
18937        // Should show 3 columns
18938        assert_eq!(app.get_column_count(), 3);
18939        assert_eq!(app.get_column_selector_max(), 3);
18940
18941        // All 3 columns should be visible by default
18942        assert_eq!(app.cloudtrail_resource_visible_column_ids.len(), 3);
18943
18944        // Toggle first column off
18945        app.column_selector_index = 1;
18946        app.handle_action(Action::ToggleColumn);
18947        assert_eq!(app.cloudtrail_resource_visible_column_ids.len(), 2);
18948
18949        // Toggle second column off
18950        app.column_selector_index = 2;
18951        app.handle_action(Action::ToggleColumn);
18952        assert_eq!(app.cloudtrail_resource_visible_column_ids.len(), 1);
18953
18954        // Try to toggle last column off - should NOT work (at least 1 must remain)
18955        app.column_selector_index = 3;
18956        app.handle_action(Action::ToggleColumn);
18957        assert_eq!(app.cloudtrail_resource_visible_column_ids.len(), 1);
18958
18959        // Toggle first column back on
18960        app.column_selector_index = 1;
18961        app.handle_action(Action::ToggleColumn);
18962        assert_eq!(app.cloudtrail_resource_visible_column_ids.len(), 2);
18963    }
18964
18965    #[test]
18966    fn test_cloudtrail_resources_preferences_tab_cycles() {
18967        let mut app = App::new_without_client("default".to_string(), None);
18968        app.service_selected = true;
18969        app.current_service = Service::CloudTrailEvents;
18970        app.mode = Mode::ColumnSelector;
18971        app.cloudtrail_state.current_event = Some(CloudTrailEvent {
18972            event_name: "PutObject".to_string(),
18973            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
18974            username: "user".to_string(),
18975            event_source: "s3.amazonaws.com".to_string(),
18976            resource_type: "AWS::S3::Bucket".to_string(),
18977            resource_name: "my-bucket".to_string(),
18978            read_only: "false".to_string(),
18979            aws_region: "us-east-1".to_string(),
18980            event_id: "90c72977-31e0-4079-9a74-ee25e5d7aadf".to_string(),
18981            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
18982            source_ip_address: "192.0.2.1".to_string(),
18983            error_code: "".to_string(),
18984            request_id: "req-123".to_string(),
18985            event_type: "AwsApiCall".to_string(),
18986            cloud_trail_event_json: r#"{"eventName":"PutObject"}"#.to_string(),
18987        });
18988        app.cloudtrail_state.detail_focus = CloudTrailDetailFocus::Resources;
18989        app.column_selector_index = 1;
18990
18991        // Tab wraps to start (no page size section)
18992        app.handle_action(Action::NextPreferences);
18993        assert_eq!(app.column_selector_index, 0);
18994
18995        // Shift+Tab wraps to start (no page size section)
18996        app.column_selector_index = 1;
18997        app.handle_action(Action::PrevPreferences);
18998        assert_eq!(app.column_selector_index, 0);
18999    }
19000
19001    #[test]
19002    fn test_cloudtrail_resources_height_stays_constant_when_expanding() {
19003        // This test verifies that table height is pre-allocated for expansion
19004        // so content doesn't jump around when expanding/collapsing
19005
19006        // Height formula: (n_rows + n_visible_cols - 1) + 1 table header + 2 borders + 1 title
19007        // For 1 row with 3 visible columns: (1 + 3 - 1) + 1 + 2 + 1 = 7
19008
19009        // With 3 visible columns
19010        let visible_cols_3 = 3;
19011        let height_3 = (1 + visible_cols_3 - 1 + 1 + 2 + 1) as u16;
19012        assert_eq!(height_3, 7);
19013
19014        // With 2 visible columns (one toggled off)
19015        let visible_cols_2 = 2;
19016        let height_2 = (1 + visible_cols_2 - 1 + 1 + 2 + 1) as u16;
19017        assert_eq!(height_2, 6);
19018
19019        // With 1 visible column (two toggled off)
19020        let visible_cols_1 = 1;
19021        let height_1 = (1 + visible_cols_1 - 1 + 1 + 2 + 1) as u16;
19022        assert_eq!(height_1, 5);
19023    }
19024
19025    #[test]
19026    fn test_cloudtrail_default_focus_is_resources() {
19027        let app = App::new_without_client("default".to_string(), None);
19028        assert_eq!(
19029            app.cloudtrail_state.detail_focus,
19030            CloudTrailDetailFocus::Resources
19031        );
19032    }
19033
19034    fn setup_cloudtrail_pagination_test() -> App {
19035        let mut app = App::new_without_client("default".to_string(), None);
19036        app.service_selected = true;
19037        app.current_service = Service::CloudTrailEvents;
19038        app.mode = Mode::FilterInput;
19039        app.cloudtrail_state.input_focus = InputFocus::Pagination;
19040        app.cloudtrail_state.table.page_size = PageSize::Ten;
19041        app.cloudtrail_state.table.items = (0..25)
19042            .map(|i| CloudTrailEvent {
19043                event_name: format!("Event{}", i),
19044                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19045                username: "user".to_string(),
19046                event_source: "s3.amazonaws.com".to_string(),
19047                resource_type: "Bucket".to_string(),
19048                resource_name: "bucket".to_string(),
19049                read_only: "false".to_string(),
19050                aws_region: "us-east-1".to_string(),
19051                event_id: "id".to_string(),
19052                access_key_id: "key".to_string(),
19053                source_ip_address: "1.2.3.4".to_string(),
19054                error_code: "".to_string(),
19055                request_id: "req".to_string(),
19056                event_type: "AwsApiCall".to_string(),
19057                cloud_trail_event_json: "{}".to_string(),
19058            })
19059            .collect();
19060        app
19061    }
19062
19063    #[test]
19064    fn test_cloudtrail_pagination_navigation() {
19065        // Test right arrow navigation
19066        let mut app = setup_cloudtrail_pagination_test();
19067        assert_eq!(app.cloudtrail_state.table.selected, 0);
19068        app.handle_action(Action::NextItem);
19069        assert_eq!(app.cloudtrail_state.table.selected, 10);
19070        app.handle_action(Action::NextItem);
19071        assert_eq!(app.cloudtrail_state.table.selected, 20);
19072        app.handle_action(Action::NextItem);
19073        assert_eq!(app.cloudtrail_state.table.selected, 20);
19074
19075        // Test left arrow navigation
19076        app.handle_action(Action::PrevItem);
19077        assert_eq!(app.cloudtrail_state.table.selected, 10);
19078        app.handle_action(Action::PrevItem);
19079        assert_eq!(app.cloudtrail_state.table.selected, 0);
19080        app.handle_action(Action::PrevItem);
19081        assert_eq!(app.cloudtrail_state.table.selected, 0);
19082    }
19083
19084    #[test]
19085    fn test_cloudtrail_pagination_navigation_right_arrow() {
19086        let mut app = setup_cloudtrail_pagination_test();
19087        app.current_service = Service::CloudTrailEvents;
19088        app.mode = Mode::FilterInput;
19089        app.cloudtrail_state.input_focus = InputFocus::Pagination;
19090        app.cloudtrail_state.table.page_size = PageSize::Ten;
19091
19092        // Create 25 items (3 pages)
19093        app.cloudtrail_state.table.items = (0..25)
19094            .map(|i| CloudTrailEvent {
19095                event_name: format!("Event{}", i),
19096                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19097                username: "user".to_string(),
19098                event_source: "s3.amazonaws.com".to_string(),
19099                resource_type: "Bucket".to_string(),
19100                resource_name: "bucket".to_string(),
19101                read_only: "false".to_string(),
19102                aws_region: "us-east-1".to_string(),
19103                event_id: "id".to_string(),
19104                access_key_id: "key".to_string(),
19105                source_ip_address: "1.2.3.4".to_string(),
19106                error_code: "".to_string(),
19107                request_id: "req".to_string(),
19108                event_type: "AwsApiCall".to_string(),
19109                cloud_trail_event_json: "{}".to_string(),
19110            })
19111            .collect();
19112
19113        // Start at page 0
19114        assert_eq!(app.cloudtrail_state.table.selected, 0);
19115
19116        // Right arrow should go to page 1
19117        app.handle_action(Action::NextItem);
19118        assert_eq!(app.cloudtrail_state.table.selected, 10);
19119
19120        // Right arrow should go to page 2
19121        app.handle_action(Action::NextItem);
19122        assert_eq!(app.cloudtrail_state.table.selected, 20);
19123
19124        // Right arrow at last page should stay
19125        app.handle_action(Action::NextItem);
19126        assert_eq!(app.cloudtrail_state.table.selected, 20);
19127    }
19128
19129    #[test]
19130    fn test_cloudtrail_pagination_navigation_left_arrow() {
19131        let mut app = App::new_without_client("default".to_string(), None);
19132        app.service_selected = true;
19133        app.current_service = Service::CloudTrailEvents;
19134        app.mode = Mode::FilterInput;
19135        app.cloudtrail_state.input_focus = InputFocus::Pagination;
19136        app.cloudtrail_state.table.page_size = PageSize::Ten;
19137
19138        // Create 25 items (3 pages)
19139        app.cloudtrail_state.table.items = (0..25)
19140            .map(|i| CloudTrailEvent {
19141                event_name: format!("Event{}", i),
19142                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19143                username: "user".to_string(),
19144                event_source: "s3.amazonaws.com".to_string(),
19145                resource_type: "Bucket".to_string(),
19146                resource_name: "bucket".to_string(),
19147                read_only: "false".to_string(),
19148                aws_region: "us-east-1".to_string(),
19149                event_id: "id".to_string(),
19150                access_key_id: "key".to_string(),
19151                source_ip_address: "1.2.3.4".to_string(),
19152                error_code: "".to_string(),
19153                request_id: "req".to_string(),
19154                event_type: "AwsApiCall".to_string(),
19155                cloud_trail_event_json: "{}".to_string(),
19156            })
19157            .collect();
19158
19159        // Start at page 2
19160        app.cloudtrail_state.table.selected = 20;
19161
19162        // Left arrow should go to page 1
19163        app.handle_action(Action::PrevItem);
19164        assert_eq!(app.cloudtrail_state.table.selected, 10);
19165
19166        // Left arrow should go to page 0
19167        app.handle_action(Action::PrevItem);
19168        assert_eq!(app.cloudtrail_state.table.selected, 0);
19169
19170        // Left arrow at first page should stay
19171        app.handle_action(Action::PrevItem);
19172        assert_eq!(app.cloudtrail_state.table.selected, 0);
19173    }
19174
19175    #[test]
19176    fn test_cloudtrail_arrow_navigation() {
19177        let mut app = App::new_without_client("default".to_string(), None);
19178        app.service_selected = true;
19179        app.current_service = Service::CloudTrailEvents;
19180        app.mode = Mode::Normal;
19181
19182        // Create 5 items
19183        app.cloudtrail_state.table.items = (0..5)
19184            .map(|i| CloudTrailEvent {
19185                event_name: format!("Event{}", i),
19186                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19187                username: "user".to_string(),
19188                event_source: "s3.amazonaws.com".to_string(),
19189                resource_type: "Bucket".to_string(),
19190                resource_name: "bucket".to_string(),
19191                read_only: "false".to_string(),
19192                aws_region: "us-east-1".to_string(),
19193                event_id: "id".to_string(),
19194                access_key_id: "key".to_string(),
19195                source_ip_address: "1.2.3.4".to_string(),
19196                error_code: "".to_string(),
19197                request_id: "req".to_string(),
19198                event_type: "AwsApiCall".to_string(),
19199                cloud_trail_event_json: "{}".to_string(),
19200            })
19201            .collect();
19202
19203        // Down arrow should move to next item
19204        app.handle_action(Action::NextItem);
19205        assert_eq!(app.cloudtrail_state.table.selected, 1);
19206
19207        app.handle_action(Action::NextItem);
19208        assert_eq!(app.cloudtrail_state.table.selected, 2);
19209
19210        // Up arrow should move to previous item
19211        app.handle_action(Action::PrevItem);
19212        assert_eq!(app.cloudtrail_state.table.selected, 1);
19213    }
19214
19215    #[test]
19216    fn test_cloudtrail_page_down_navigation() {
19217        let mut app = App::new_without_client("default".to_string(), None);
19218        app.service_selected = true;
19219        app.current_service = Service::CloudTrailEvents;
19220        app.mode = Mode::Normal;
19221        app.cloudtrail_state.table.page_size = PageSize::Ten;
19222
19223        // Create 25 items
19224        app.cloudtrail_state.table.items = (0..25)
19225            .map(|i| CloudTrailEvent {
19226                event_name: format!("Event{}", i),
19227                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19228                username: "user".to_string(),
19229                event_source: "s3.amazonaws.com".to_string(),
19230                resource_type: "Bucket".to_string(),
19231                resource_name: "bucket".to_string(),
19232                read_only: "false".to_string(),
19233                aws_region: "us-east-1".to_string(),
19234                event_id: "id".to_string(),
19235                access_key_id: "key".to_string(),
19236                source_ip_address: "1.2.3.4".to_string(),
19237                error_code: "".to_string(),
19238                request_id: "req".to_string(),
19239                event_type: "AwsApiCall".to_string(),
19240                cloud_trail_event_json: "{}".to_string(),
19241            })
19242            .collect();
19243
19244        // Ctrl+D should jump down a page
19245        app.handle_action(Action::PageDown);
19246        assert_eq!(app.cloudtrail_state.table.selected, 10);
19247
19248        app.handle_action(Action::PageDown);
19249        assert_eq!(app.cloudtrail_state.table.selected, 20);
19250    }
19251
19252    #[test]
19253    fn test_cloudtrail_page_up_navigation() {
19254        let mut app = App::new_without_client("default".to_string(), None);
19255        app.service_selected = true;
19256        app.current_service = Service::CloudTrailEvents;
19257        app.mode = Mode::Normal;
19258        app.cloudtrail_state.table.page_size = PageSize::Ten;
19259
19260        // Create 25 items
19261        app.cloudtrail_state.table.items = (0..25)
19262            .map(|i| CloudTrailEvent {
19263                event_name: format!("Event{}", i),
19264                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19265                username: "user".to_string(),
19266                event_source: "s3.amazonaws.com".to_string(),
19267                resource_type: "Bucket".to_string(),
19268                resource_name: "bucket".to_string(),
19269                read_only: "false".to_string(),
19270                aws_region: "us-east-1".to_string(),
19271                event_id: "id".to_string(),
19272                access_key_id: "key".to_string(),
19273                source_ip_address: "1.2.3.4".to_string(),
19274                error_code: "".to_string(),
19275                request_id: "req".to_string(),
19276                event_type: "AwsApiCall".to_string(),
19277                cloud_trail_event_json: "{}".to_string(),
19278            })
19279            .collect();
19280
19281        // Start at page 2
19282        app.cloudtrail_state.table.selected = 20;
19283
19284        // Ctrl+U should jump up a page
19285        app.handle_action(Action::PageUp);
19286        assert_eq!(app.cloudtrail_state.table.selected, 10);
19287
19288        app.handle_action(Action::PageUp);
19289        assert_eq!(app.cloudtrail_state.table.selected, 0);
19290    }
19291
19292    #[test]
19293    fn test_cloudtrail_page_size_change_updates_display() {
19294        let mut app = App::new_without_client("default".to_string(), None);
19295        app.service_selected = true;
19296        app.current_service = Service::CloudTrailEvents;
19297        app.mode = Mode::ColumnSelector;
19298
19299        // Create 50 items
19300        app.cloudtrail_state.table.items = (0..50)
19301            .map(|i| CloudTrailEvent {
19302                event_name: format!("Event{}", i),
19303                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19304                username: "user".to_string(),
19305                event_source: "s3.amazonaws.com".to_string(),
19306                resource_type: "Bucket".to_string(),
19307                resource_name: "bucket".to_string(),
19308                read_only: "false".to_string(),
19309                aws_region: "us-east-1".to_string(),
19310                event_id: "id".to_string(),
19311                access_key_id: "key".to_string(),
19312                source_ip_address: "1.2.3.4".to_string(),
19313                error_code: "".to_string(),
19314                request_id: "req".to_string(),
19315                event_type: "AwsApiCall".to_string(),
19316                cloud_trail_event_json: "{}".to_string(),
19317            })
19318            .collect();
19319
19320        // Default page size is 50
19321        assert_eq!(app.cloudtrail_state.table.page_size, PageSize::Fifty);
19322
19323        // Change to page size 10 (index 17)
19324        app.column_selector_index = 17;
19325        app.handle_action(Action::ToggleColumn);
19326
19327        assert_eq!(app.cloudtrail_state.table.page_size, PageSize::Ten);
19328        // snap_to_page should have been called to adjust display
19329    }
19330
19331    #[test]
19332    fn test_cloudtrail_all_columns_toggleable() {
19333        let mut app = App::new_without_client("default".to_string(), None);
19334        app.service_selected = true;
19335        app.current_service = Service::CloudTrailEvents;
19336        app.mode = Mode::ColumnSelector;
19337
19338        // Verify we have 14 columns
19339        assert_eq!(app.cloudtrail_event_column_ids.len(), 14);
19340
19341        // Test toggling each column (indices 1-14)
19342        // Note: Cannot toggle off the last visible column
19343        for idx in 1..=14 {
19344            app.column_selector_index = idx;
19345            let initial_visible = app.cloudtrail_event_visible_column_ids.clone();
19346            let initial_count = initial_visible.len();
19347
19348            // Check if this column is currently visible
19349            let col = app.cloudtrail_event_column_ids.get(idx - 1).unwrap();
19350            let is_visible = initial_visible.contains(col);
19351
19352            app.handle_action(Action::ToggleColumn);
19353
19354            // If it was the last visible column, it should not be removed
19355            if is_visible && initial_count == 1 {
19356                assert_eq!(
19357                    app.cloudtrail_event_visible_column_ids, initial_visible,
19358                    "Last visible column at index {} should not be toggleable",
19359                    idx
19360                );
19361            } else {
19362                // Otherwise it should toggle
19363                assert_ne!(
19364                    app.cloudtrail_event_visible_column_ids, initial_visible,
19365                    "Column at index {} should be toggleable when not the last one",
19366                    idx
19367                );
19368            }
19369        }
19370    }
19371
19372    #[test]
19373    fn test_cloudtrail_readonly_column_toggleable() {
19374        use crate::cloudtrail::events::CloudTrailEventColumn;
19375
19376        let mut app = App::new_without_client("default".to_string(), None);
19377        app.service_selected = true;
19378        app.current_service = Service::CloudTrailEvents;
19379        app.mode = Mode::ColumnSelector;
19380
19381        // ReadOnly is at position 6 (0-indexed) in the all() array
19382        // So it's at index 7 in the column selector (1-indexed)
19383        let readonly_id = CloudTrailEventColumn::ReadOnly.id();
19384
19385        // Verify ReadOnly is in the column list
19386        assert!(app.cloudtrail_event_column_ids.contains(&readonly_id));
19387
19388        // Initially not visible
19389        assert!(!app
19390            .cloudtrail_event_visible_column_ids
19391            .contains(&readonly_id));
19392
19393        // Toggle it on (index 7)
19394        app.column_selector_index = 7;
19395        app.handle_action(Action::ToggleColumn);
19396
19397        // Should now be visible
19398        assert!(
19399            app.cloudtrail_event_visible_column_ids
19400                .contains(&readonly_id),
19401            "ReadOnly column should be toggleable at index 7"
19402        );
19403
19404        // Toggle it off
19405        app.handle_action(Action::ToggleColumn);
19406
19407        // Should be hidden again
19408        assert!(!app
19409            .cloudtrail_event_visible_column_ids
19410            .contains(&readonly_id));
19411    }
19412
19413    #[test]
19414    fn test_cloudtrail_pagination_limits_displayed_items() {
19415        let mut app = App::new_without_client("default".to_string(), None);
19416        app.service_selected = true;
19417        app.current_service = Service::CloudTrailEvents;
19418
19419        // Create 50 items
19420        app.cloudtrail_state.table.items = (0..50)
19421            .map(|i| CloudTrailEvent {
19422                event_name: format!("Event{}", i),
19423                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19424                username: "user".to_string(),
19425                event_source: "s3.amazonaws.com".to_string(),
19426                resource_type: "Bucket".to_string(),
19427                resource_name: "bucket".to_string(),
19428                read_only: "false".to_string(),
19429                aws_region: "us-east-1".to_string(),
19430                event_id: "id".to_string(),
19431                access_key_id: "key".to_string(),
19432                source_ip_address: "1.2.3.4".to_string(),
19433                error_code: "".to_string(),
19434                request_id: "req".to_string(),
19435                event_type: "AwsApiCall".to_string(),
19436                cloud_trail_event_json: "{}".to_string(),
19437            })
19438            .collect();
19439
19440        // Set page size to 10
19441        app.cloudtrail_state.table.page_size = PageSize::Ten;
19442
19443        // Render to a test backend to verify pagination
19444        use ratatui::backend::TestBackend;
19445        use ratatui::Terminal;
19446
19447        let backend = TestBackend::new(100, 30);
19448        let mut terminal = Terminal::new(backend).unwrap();
19449
19450        terminal
19451            .draw(|frame| {
19452                let area = frame.area();
19453                crate::ui::cloudtrail::render_events(frame, &app, area);
19454            })
19455            .unwrap();
19456
19457        // The rendering should only show 10 items per page
19458        // This is verified by the pagination logic in render_events
19459        assert_eq!(app.cloudtrail_state.table.page_size, PageSize::Ten);
19460    }
19461
19462    #[test]
19463    fn test_cloudtrail_readonly_column_selectable_in_preferences() {
19464        let mut app = App::new_without_client("default".to_string(), None);
19465        app.service_selected = true;
19466        app.current_service = Service::CloudTrailEvents;
19467        app.mode = Mode::ColumnSelector;
19468
19469        // Verify we have 14 columns
19470        assert_eq!(app.cloudtrail_event_column_ids.len(), 14);
19471
19472        // Navigate to Read-only column (index 7)
19473        app.column_selector_index = 7;
19474
19475        // Verify we can select it by checking the index is valid
19476        let max_index = app.get_column_selector_max();
19477        assert!(
19478            app.column_selector_index <= max_index,
19479            "Read-only column index {} should be <= max {}",
19480            app.column_selector_index,
19481            max_index
19482        );
19483
19484        // Verify the column exists at this index
19485        let col_id = &app.cloudtrail_event_column_ids[app.column_selector_index - 1];
19486        let col = CloudTrailEventColumn::from_id(col_id);
19487        assert!(col.is_some(), "Column should exist at index 7");
19488        assert_eq!(
19489            col.unwrap().default_name(),
19490            "Read-only",
19491            "Column at index 7 should be Read-only"
19492        );
19493    }
19494
19495    #[test]
19496    fn test_cloudtrail_navigate_to_readonly_column() {
19497        let mut app = App::new_without_client("default".to_string(), None);
19498        app.service_selected = true;
19499        app.current_service = Service::CloudTrailEvents;
19500        app.mode = Mode::ColumnSelector;
19501        app.column_selector_index = 1; // Start at first column
19502
19503        // Check column IDs are initialized
19504        println!(
19505            "cloudtrail_event_column_ids.len() = {}",
19506            app.cloudtrail_event_column_ids.len()
19507        );
19508        assert_eq!(
19509            app.cloudtrail_event_column_ids.len(),
19510            14,
19511            "Should have 14 columns"
19512        );
19513
19514        // Check blank row index
19515        let column_count = app.get_column_count();
19516        println!("Column count from get_column_count(): {}", column_count);
19517        assert_eq!(column_count, 14, "Column count should be 14");
19518
19519        // The blank row should be at index 15 (14 columns + 1)
19520        assert!(!app.is_blank_row_index(7), "Index 7 should NOT be blank");
19521        assert!(app.is_blank_row_index(15), "Index 15 should be blank");
19522
19523        // Navigate down to Read-only column (index 7)
19524        for _ in 0..6 {
19525            app.handle_action(Action::NextItem);
19526        }
19527
19528        assert_eq!(
19529            app.column_selector_index, 7,
19530            "Should navigate to Read-only column at index 7"
19531        );
19532
19533        // Verify it's the Read-only column
19534        let col_id = &app.cloudtrail_event_column_ids[app.column_selector_index - 1];
19535        let col = CloudTrailEventColumn::from_id(col_id).unwrap();
19536        assert_eq!(col.default_name(), "Read-only");
19537    }
19538
19539    #[test]
19540    fn test_cloudtrail_navigate_beyond_loaded_items() {
19541        let mut app = App::new_without_client("default".to_string(), None);
19542        app.service_selected = true;
19543        app.current_service = Service::CloudTrailEvents;
19544
19545        // Load only 50 items
19546        app.cloudtrail_state.table.items = (0..50)
19547            .map(|i| CloudTrailEvent {
19548                event_name: format!("Event{}", i),
19549                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19550                username: "user".to_string(),
19551                event_source: "s3.amazonaws.com".to_string(),
19552                resource_type: "Bucket".to_string(),
19553                resource_name: "bucket".to_string(),
19554                read_only: "false".to_string(),
19555                aws_region: "us-east-1".to_string(),
19556                event_id: "id".to_string(),
19557                access_key_id: "key".to_string(),
19558                source_ip_address: "1.2.3.4".to_string(),
19559                error_code: "".to_string(),
19560                request_id: "req".to_string(),
19561                event_type: "AwsApiCall".to_string(),
19562                cloud_trail_event_json: "{}".to_string(),
19563            })
19564            .collect();
19565
19566        // Page size is 50 by default, so we have 1 page of data
19567        // Try to navigate to page 10 (beyond loaded data)
19568        let initial_selected = app.cloudtrail_state.table.selected;
19569        app.page_input = "10".to_string();
19570        app.go_to_page(10);
19571
19572        // Should do nothing (stay at initial position)
19573        assert_eq!(
19574            app.cloudtrail_state.table.selected, initial_selected,
19575            "Should ignore navigation to page 10 when only 1 page is loaded"
19576        );
19577
19578        // But page 2 should work (loaded + 1)
19579        app.go_to_page(2);
19580        let page_size = app.cloudtrail_state.table.page_size.value();
19581        assert_eq!(
19582            app.cloudtrail_state.table.selected, page_size,
19583            "Should allow navigation to page 2 (loaded + 1)"
19584        );
19585    }
19586
19587    #[test]
19588    fn test_cloudtrail_page_change_resets_expansion() {
19589        let mut app = App::new_without_client("default".to_string(), None);
19590        app.service_selected = true;
19591        app.current_service = Service::CloudTrailEvents;
19592
19593        // Create 100 items (2 pages with page size 50)
19594        app.cloudtrail_state.table.items = (0..100)
19595            .map(|i| CloudTrailEvent {
19596                event_name: format!("Event{}", i),
19597                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19598                username: "user".to_string(),
19599                event_source: "s3.amazonaws.com".to_string(),
19600                resource_type: "Bucket".to_string(),
19601                resource_name: "bucket".to_string(),
19602                read_only: "false".to_string(),
19603                aws_region: "us-east-1".to_string(),
19604                event_id: "id".to_string(),
19605                access_key_id: "key".to_string(),
19606                source_ip_address: "1.2.3.4".to_string(),
19607                error_code: "".to_string(),
19608                request_id: "req".to_string(),
19609                event_type: "AwsApiCall".to_string(),
19610                cloud_trail_event_json: "{}".to_string(),
19611            })
19612            .collect();
19613
19614        // Expand an item on page 1
19615        app.cloudtrail_state.table.expanded_item = Some(5);
19616        assert_eq!(app.cloudtrail_state.table.expanded_item, Some(5));
19617
19618        // Navigate to page 2
19619        app.go_to_page(2);
19620
19621        // Expansion should be reset
19622        assert_eq!(
19623            app.cloudtrail_state.table.expanded_item, None,
19624            "Page change should reset expanded_item"
19625        );
19626    }
19627
19628    #[test]
19629    fn test_cloudtrail_pagination_right_left_arrow_works_when_filter_focused() {
19630        // Regression: Left/Right arrows map to PageUp/PageDown in FilterInput mode.
19631        // page_down_filter_input called handle_page_down which only acts when
19632        // input_focus == Pagination. When filter is focused, it was a no-op.
19633        let mut app = App::new_without_client("default".to_string(), None);
19634        app.service_selected = true;
19635        app.current_service = Service::CloudTrailEvents;
19636        app.mode = Mode::FilterInput;
19637        // Focus is on the FILTER text input (not pagination)
19638        app.cloudtrail_state.input_focus = InputFocus::Filter;
19639        app.cloudtrail_state.table.page_size = PageSize::Ten;
19640
19641        app.cloudtrail_state.table.items = (0..25)
19642            .map(|i| crate::cloudtrail::CloudTrailEvent {
19643                event_name: format!("Event{}", i),
19644                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19645                username: "user".to_string(),
19646                event_source: "s3.amazonaws.com".to_string(),
19647                resource_type: "Bucket".to_string(),
19648                resource_name: "bucket".to_string(),
19649                read_only: "false".to_string(),
19650                aws_region: "us-east-1".to_string(),
19651                event_id: format!("id-{}", i),
19652                access_key_id: "key".to_string(),
19653                source_ip_address: "1.2.3.4".to_string(),
19654                error_code: String::new(),
19655                request_id: "req".to_string(),
19656                event_type: "AwsApiCall".to_string(),
19657                cloud_trail_event_json: "{}".to_string(),
19658            })
19659            .collect();
19660
19661        assert_eq!(app.cloudtrail_state.table.selected, 0, "starts on page 1");
19662
19663        // Right arrow (PageDown) must go to page 2 even when filter is focused
19664        app.handle_action(Action::PageDown);
19665        assert_eq!(
19666            app.cloudtrail_state.table.selected, 10,
19667            "Right arrow must advance to page 2 even when filter text box is focused"
19668        );
19669
19670        // Left arrow (PageUp) must go back to page 1
19671        app.handle_action(Action::PageUp);
19672        assert_eq!(
19673            app.cloudtrail_state.table.selected, 0,
19674            "Left arrow must go back to page 1 even when filter text box is focused"
19675        );
19676    }
19677
19678    #[test]
19679    fn test_cloudtrail_yank_in_event_detail_copies_json() {
19680        // y key in event detail view must copy cloud_trail_event_json to clipboard.
19681        let mut app = test_app();
19682        app.current_service = Service::CloudTrailEvents;
19683        app.service_selected = true;
19684        app.cloudtrail_state.current_event = Some(CloudTrailEvent {
19685            event_name: "PutObject".to_string(),
19686            event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19687            username: "alice".to_string(),
19688            event_source: "s3.amazonaws.com".to_string(),
19689            resource_type: "Bucket".to_string(),
19690            resource_name: "my-bucket".to_string(),
19691            read_only: "false".to_string(),
19692            aws_region: "us-east-1".to_string(),
19693            event_id: "evt-001".to_string(),
19694            access_key_id: "AKIA".to_string(),
19695            source_ip_address: "1.2.3.4".to_string(),
19696            error_code: String::new(),
19697            request_id: "req-001".to_string(),
19698            event_type: "AwsApiCall".to_string(),
19699            cloud_trail_event_json: r#"{"eventName":"PutObject"}"#.to_string(),
19700        });
19701
19702        // yank must not panic — we can't assert clipboard contents in tests
19703        // but verify the function is reachable
19704        app.handle_action(Action::Yank);
19705        // event must still be open after yank (yank is non-destructive)
19706        assert!(
19707            app.cloudtrail_state.current_event.is_some(),
19708            "Event must remain open after yank"
19709        );
19710    }
19711
19712    #[test]
19713    fn test_cloudtrail_filter_reduces_visible_events() {
19714        // Regression: render_events was collecting all items without applying
19715        // cloudtrail_state.table.filter — search had no visible effect.
19716        // We test the filter application directly via filtered_events helper
19717        // (the render path is tested via UI tests; here we test the data path).
19718        let mut app = test_app();
19719        app.current_service = Service::CloudTrailEvents;
19720        app.service_selected = true;
19721
19722        let items: Vec<CloudTrailEvent> = vec![
19723            CloudTrailEvent {
19724                event_name: "PutObject".to_string(),
19725                event_time: "2024-01-01 10:00:00 (UTC)".to_string(),
19726                username: "alice".to_string(),
19727                event_source: "s3.amazonaws.com".to_string(),
19728                resource_type: "Bucket".to_string(),
19729                resource_name: "my-bucket".to_string(),
19730                read_only: "false".to_string(),
19731                aws_region: "us-east-1".to_string(),
19732                event_id: "evt-001".to_string(),
19733                access_key_id: "AKIA".to_string(),
19734                source_ip_address: "1.2.3.4".to_string(),
19735                error_code: String::new(),
19736                request_id: "req-001".to_string(),
19737                event_type: "AwsApiCall".to_string(),
19738                cloud_trail_event_json: "{}".to_string(),
19739            },
19740            CloudTrailEvent {
19741                event_name: "GetObject".to_string(),
19742                event_time: "2024-01-01 11:00:00 (UTC)".to_string(),
19743                username: "bob".to_string(),
19744                event_source: "s3.amazonaws.com".to_string(),
19745                resource_type: "Object".to_string(),
19746                resource_name: "my-key".to_string(),
19747                read_only: "true".to_string(),
19748                aws_region: "us-east-1".to_string(),
19749                event_id: "evt-002".to_string(),
19750                access_key_id: "AKIA2".to_string(),
19751                source_ip_address: "5.6.7.8".to_string(),
19752                error_code: String::new(),
19753                request_id: "req-002".to_string(),
19754                event_type: "AwsApiCall".to_string(),
19755                cloud_trail_event_json: "{}".to_string(),
19756            },
19757        ];
19758        app.cloudtrail_state.table.items = items;
19759
19760        // No filter — both events visible
19761        let q = app.cloudtrail_state.table.filter.to_lowercase();
19762        let filtered: Vec<_> = app
19763            .cloudtrail_state
19764            .table
19765            .items
19766            .iter()
19767            .filter(|e| {
19768                q.is_empty()
19769                    || e.event_name.to_lowercase().contains(&q)
19770                    || e.username.to_lowercase().contains(&q)
19771            })
19772            .collect();
19773        assert_eq!(
19774            filtered.len(),
19775            2,
19776            "No filter: both events should be visible"
19777        );
19778
19779        // Filter by event name
19780        app.cloudtrail_state.table.filter = "PutObject".to_string();
19781        let q = app.cloudtrail_state.table.filter.to_lowercase();
19782        let filtered: Vec<_> = app
19783            .cloudtrail_state
19784            .table
19785            .items
19786            .iter()
19787            .filter(|e| {
19788                q.is_empty()
19789                    || e.event_name.to_lowercase().contains(&q)
19790                    || e.username.to_lowercase().contains(&q)
19791            })
19792            .collect();
19793        assert_eq!(
19794            filtered.len(),
19795            1,
19796            "Filter 'PutObject' should show only 1 event"
19797        );
19798        assert_eq!(filtered[0].event_name, "PutObject");
19799
19800        // Filter by username
19801        app.cloudtrail_state.table.filter = "bob".to_string();
19802        let q = app.cloudtrail_state.table.filter.to_lowercase();
19803        let filtered: Vec<_> = app
19804            .cloudtrail_state
19805            .table
19806            .items
19807            .iter()
19808            .filter(|e| {
19809                q.is_empty()
19810                    || e.event_name.to_lowercase().contains(&q)
19811                    || e.username.to_lowercase().contains(&q)
19812            })
19813            .collect();
19814        assert_eq!(filtered.len(), 1, "Filter 'bob' should show only 1 event");
19815        assert_eq!(filtered[0].username, "bob");
19816    }
19817
19818    #[test]
19819    fn test_lambda_application_resources_have_columns() {
19820        let app = test_app();
19821
19822        // Verify lambda resource columns are initialized correctly
19823        assert_eq!(app.lambda_resource_visible_column_ids.len(), 4);
19824        assert_eq!(app.lambda_resource_column_ids.len(), 4);
19825
19826        // Verify they contain the expected Lambda ResourceColumn IDs
19827        assert!(app
19828            .lambda_resource_visible_column_ids
19829            .contains(&"column.lambda.resource.logical_id"));
19830        assert!(app
19831            .lambda_resource_column_ids
19832            .contains(&"column.lambda.resource.logical_id"));
19833    }
19834
19835    #[test]
19836    fn test_lambda_functions_list_right_expands_left_collapses() {
19837        use crate::lambda::Function;
19838        let mut app = test_app();
19839        app.mode = Mode::Normal;
19840        app.current_service = Service::LambdaFunctions;
19841        app.service_selected = true;
19842        app.lambda_state.table.items = vec![Function {
19843            name: "fn1".to_string(),
19844            arn: "arn::fn1".to_string(),
19845            application: None,
19846            description: String::new(),
19847            package_type: String::new(),
19848            runtime: "python3.12".to_string(),
19849            architecture: String::new(),
19850            code_size: 0,
19851            code_sha256: String::new(),
19852            memory_mb: 128,
19853            timeout_seconds: 3,
19854            last_modified: String::new(),
19855            layers: vec![],
19856        }];
19857        app.lambda_state.table.selected = 0;
19858
19859        // Right arrow should expand
19860        app.handle_action(Action::NextPane);
19861        assert_eq!(app.lambda_state.table.expanded_item, Some(0));
19862
19863        // Right arrow again should NOT collapse (stays expanded)
19864        app.handle_action(Action::NextPane);
19865        assert_eq!(app.lambda_state.table.expanded_item, Some(0));
19866
19867        // Left arrow should collapse
19868        app.handle_action(Action::CollapseRow);
19869        assert_eq!(app.lambda_state.table.expanded_item, None);
19870    }
19871
19872    #[test]
19873    fn test_lambda_applications_list_right_expands_left_collapses() {
19874        use crate::lambda::Application;
19875        let mut app = test_app();
19876        app.mode = Mode::Normal;
19877        app.current_service = Service::LambdaApplications;
19878        app.service_selected = true;
19879        app.lambda_application_state.table.items = vec![Application {
19880            name: "my-app".to_string(),
19881            arn: String::new(),
19882            description: String::new(),
19883            status: "ACTIVE".to_string(),
19884            last_modified: String::new(),
19885        }];
19886        app.lambda_application_state.table.selected = 0;
19887
19888        // Right arrow should expand
19889        app.handle_action(Action::NextPane);
19890        assert_eq!(app.lambda_application_state.table.expanded_item, Some(0));
19891
19892        // Right arrow again should NOT collapse (stays expanded)
19893        app.handle_action(Action::NextPane);
19894        assert_eq!(app.lambda_application_state.table.expanded_item, Some(0));
19895
19896        // Left arrow should collapse
19897        app.handle_action(Action::CollapseRow);
19898        assert_eq!(app.lambda_application_state.table.expanded_item, None);
19899    }
19900
19901    #[test]
19902    fn test_lambda_application_enter_on_deployments_tab_does_not_switch_to_overview() {
19903        use crate::lambda::Application;
19904        let mut app = test_app();
19905        app.mode = Mode::Normal;
19906        app.current_service = Service::LambdaApplications;
19907        app.service_selected = true;
19908        app.lambda_application_state.table.items = vec![Application {
19909            name: "my-app".to_string(),
19910            arn: String::new(),
19911            description: String::new(),
19912            status: "ACTIVE".to_string(),
19913            last_modified: String::new(),
19914        }];
19915
19916        // Select the application (Enter on list)
19917        app.handle_action(Action::Select);
19918        assert!(app.lambda_application_state.current_application.is_some());
19919
19920        // Switch to Deployments tab
19921        use crate::ui::lambda::ApplicationDetailTab as LambdaApplicationDetailTab;
19922        app.lambda_application_state.detail_tab = LambdaApplicationDetailTab::Deployments;
19923        assert_eq!(
19924            app.lambda_application_state.detail_tab,
19925            LambdaApplicationDetailTab::Deployments
19926        );
19927
19928        // Enter on Deployments tab must NOT switch back to Overview
19929        app.handle_action(Action::Select);
19930        assert_eq!(
19931            app.lambda_application_state.detail_tab,
19932            LambdaApplicationDetailTab::Deployments
19933        );
19934    }
19935
19936    #[test]
19937    fn test_lambda_applications_pagination_focus_left_right_jump_pages() {
19938        use crate::lambda::Application;
19939        let mut app = test_app();
19940        app.current_service = Service::LambdaApplications;
19941        app.service_selected = true;
19942        app.mode = Mode::FilterInput;
19943        app.lambda_application_state.input_focus = InputFocus::Pagination;
19944
19945        // 100 apps, page size 50 — 2 pages
19946        app.lambda_application_state.table.items = (0..100)
19947            .map(|i| Application {
19948                name: format!("app{:03}", i),
19949                arn: String::new(),
19950                description: String::new(),
19951                status: "ACTIVE".to_string(),
19952                last_modified: String::new(),
19953            })
19954            .collect();
19955        app.lambda_application_state.table.selected = 0;
19956
19957        // Right arrow (PageDown) must jump to page 2
19958        app.handle_action(Action::PageDown);
19959        assert_eq!(
19960            app.lambda_application_state.table.selected, 50,
19961            "Right arrow with pagination focus should jump to page 2"
19962        );
19963
19964        // Left arrow (PageUp) must jump back to page 1
19965        app.handle_action(Action::PageUp);
19966        assert_eq!(
19967            app.lambda_application_state.table.selected, 0,
19968            "Left arrow with pagination focus should jump back to page 1"
19969        );
19970
19971        // Up/Down arrows must NOT move table selection when pagination is focused
19972        app.handle_action(Action::NextItem);
19973        assert_eq!(
19974            app.lambda_application_state.table.selected, 0,
19975            "Down arrow must not move table when pagination is focused"
19976        );
19977        app.lambda_application_state.table.selected = 5;
19978        app.handle_action(Action::PrevItem);
19979        assert_eq!(
19980            app.lambda_application_state.table.selected, 5,
19981            "Up arrow must not move table when pagination is focused"
19982        );
19983    }
19984
19985    #[test]
19986    fn test_cloudwatch_alarms_enter_drills_down() {
19987        let mut app = test_app();
19988        app.mode = Mode::Normal;
19989        app.current_service = Service::CloudWatchAlarms;
19990        app.service_selected = true;
19991        app.view_mode = ViewMode::List;
19992
19993        // Add test alarm
19994        app.alarms_state.table.items = vec![Alarm {
19995            name: "test-alarm".to_string(),
19996            state: "ALARM".to_string(),
19997            state_updated_timestamp: "2024-01-01 12:00:00".to_string(),
19998            description: "Test alarm".to_string(),
19999            metric_name: "CPUUtilization".to_string(),
20000            namespace: "AWS/EC2".to_string(),
20001            statistic: "Average".to_string(),
20002            period: 300,
20003            comparison_operator: "GreaterThanThreshold".to_string(),
20004            threshold: 80.0,
20005            actions_enabled: true,
20006            state_reason: "Threshold crossed".to_string(),
20007            resource: "".to_string(),
20008            dimensions: "".to_string(),
20009            expression: "".to_string(),
20010            alarm_type: "MetricAlarm".to_string(),
20011            cross_account: "".to_string(),
20012            ..Default::default()
20013        }];
20014
20015        assert!(app.alarms_state.current_alarm.is_none());
20016        assert_eq!(app.view_mode, ViewMode::List);
20017        assert!(!app.alarms_state.metrics_loading);
20018
20019        // Press Enter - should drill into alarm and trigger metrics loading
20020        app.handle_action(Action::Select);
20021
20022        assert_eq!(
20023            app.alarms_state.current_alarm,
20024            Some("test-alarm".to_string())
20025        );
20026        assert_eq!(app.view_mode, ViewMode::Detail);
20027        assert!(
20028            app.alarms_state.metrics_loading,
20029            "Should trigger metrics loading"
20030        );
20031    }
20032
20033    #[test]
20034    fn test_cloudwatch_alarms_metric_data_renders() {
20035        let mut app = test_app();
20036        app.mode = Mode::Normal;
20037        app.current_service = Service::CloudWatchAlarms;
20038        app.service_selected = true;
20039        app.view_mode = ViewMode::Detail;
20040
20041        // Add test alarm
20042        app.alarms_state.table.items = vec![Alarm {
20043            name: "test-alarm".to_string(),
20044            state: "ALARM".to_string(),
20045            state_updated_timestamp: "2024-01-01 12:00:00".to_string(),
20046            description: "Test alarm".to_string(),
20047            metric_name: "CPUUtilization".to_string(),
20048            namespace: "AWS/EC2".to_string(),
20049            statistic: "Average".to_string(),
20050            period: 300,
20051            comparison_operator: "GreaterThanThreshold".to_string(),
20052            threshold: 80.0,
20053            actions_enabled: true,
20054            state_reason: "Threshold crossed".to_string(),
20055            resource: "".to_string(),
20056            dimensions: "".to_string(),
20057            expression: "".to_string(),
20058            alarm_type: "MetricAlarm".to_string(),
20059            cross_account: "".to_string(),
20060            ..Default::default()
20061        }];
20062
20063        app.alarms_state.current_alarm = Some("test-alarm".to_string());
20064
20065        // Add metric data
20066        app.alarms_state.metric_data = vec![(1000, 50.0), (2000, 60.0), (3000, 70.0)];
20067        app.alarms_state.metrics_loading = false;
20068
20069        // Verify metric data is present and will be rendered
20070        assert!(!app.alarms_state.metric_data.is_empty());
20071        assert_eq!(app.alarms_state.metric_data.len(), 3);
20072        assert!(!app.alarms_state.metrics_loading);
20073    }
20074
20075    #[test]
20076    fn test_cloudwatch_alarms_back_clears_metrics() {
20077        let mut app = test_app();
20078        app.mode = Mode::Normal;
20079        app.current_service = Service::CloudWatchAlarms;
20080        app.service_selected = true;
20081        app.view_mode = ViewMode::Detail;
20082
20083        // Set up alarm detail view with metrics
20084        app.alarms_state.current_alarm = Some("test-alarm".to_string());
20085        app.alarms_state.metric_data = vec![(1000, 50.0), (2000, 60.0)];
20086
20087        assert!(!app.alarms_state.metric_data.is_empty());
20088
20089        // Go back
20090        app.handle_action(Action::GoBack);
20091
20092        // Should clear current alarm and metrics
20093        assert!(app.alarms_state.current_alarm.is_none());
20094        assert!(app.alarms_state.metric_data.is_empty());
20095        assert_eq!(app.view_mode, ViewMode::List);
20096    }
20097
20098    #[test]
20099    fn test_cloudwatch_alarms_refresh_reloads_metrics() {
20100        let mut app = test_app();
20101        app.mode = Mode::Normal;
20102        app.current_service = Service::CloudWatchAlarms;
20103        app.service_selected = true;
20104        app.view_mode = ViewMode::Detail;
20105
20106        // Set up alarm detail view with old metrics
20107        app.alarms_state.current_alarm = Some("test-alarm".to_string());
20108        app.alarms_state.metric_data = vec![(1000, 50.0), (2000, 60.0)];
20109        app.alarms_state.metrics_loading = false;
20110
20111        assert!(!app.alarms_state.metric_data.is_empty());
20112        assert!(!app.alarms_state.metrics_loading);
20113
20114        // Refresh with Ctrl+R
20115        app.handle_action(Action::Refresh);
20116
20117        // Should keep old metrics visible until new data arrives
20118        assert!(!app.alarms_state.metric_data.is_empty());
20119        assert!(app.alarms_state.metrics_loading);
20120    }
20121
20122    #[test]
20123    fn test_cloudwatch_alarms_right_arrow_expands() {
20124        let mut app = test_app();
20125        app.mode = Mode::Normal;
20126        app.current_service = Service::CloudWatchAlarms;
20127        app.service_selected = true;
20128        app.view_mode = ViewMode::List;
20129
20130        // Add test alarm
20131        app.alarms_state.table.items = vec![Alarm {
20132            name: "test-alarm".to_string(),
20133            state: "ALARM".to_string(),
20134            state_updated_timestamp: "2024-01-01 12:00:00".to_string(),
20135            description: "Test alarm".to_string(),
20136            metric_name: "CPUUtilization".to_string(),
20137            namespace: "AWS/EC2".to_string(),
20138            statistic: "Average".to_string(),
20139            period: 300,
20140            comparison_operator: "GreaterThanThreshold".to_string(),
20141            threshold: 80.0,
20142            actions_enabled: true,
20143            state_reason: "Threshold crossed".to_string(),
20144            resource: "".to_string(),
20145            dimensions: "".to_string(),
20146            expression: "".to_string(),
20147            alarm_type: "MetricAlarm".to_string(),
20148            cross_account: "".to_string(),
20149            ..Default::default()
20150        }];
20151
20152        assert!(!app.alarms_state.table.is_expanded());
20153
20154        // Right arrow should expand
20155        app.handle_action(Action::NextPane);
20156        assert!(app.alarms_state.table.is_expanded());
20157
20158        // Left arrow should collapse
20159        app.handle_action(Action::PrevPane);
20160        assert!(!app.alarms_state.table.is_expanded());
20161    }
20162
20163    #[test]
20164    fn test_cloudwatch_alarms_tab_switches_tabs() {
20165        let mut app = test_app();
20166        app.mode = Mode::Normal;
20167        app.current_service = Service::CloudWatchAlarms;
20168        app.service_selected = true;
20169
20170        // Start on All Alarms tab
20171        assert_eq!(app.alarms_state.alarm_tab, AlarmTab::AllAlarms);
20172
20173        // Tab switches to In Alarm
20174        app.handle_action(Action::NextDetailTab);
20175        assert_eq!(app.alarms_state.alarm_tab, AlarmTab::InAlarm);
20176
20177        // Tab again switches back to All Alarms
20178        app.handle_action(Action::NextDetailTab);
20179        assert_eq!(app.alarms_state.alarm_tab, AlarmTab::AllAlarms);
20180
20181        // Shift+Tab switches to In Alarm
20182        app.handle_action(Action::PrevDetailTab);
20183        assert_eq!(app.alarms_state.alarm_tab, AlarmTab::InAlarm);
20184    }
20185
20186    #[test]
20187    fn test_cloudwatch_alarms_pagination_changes_table_contents() {
20188        // Regression: alarms render was passing all items to render_table without
20189        // slicing to the current page, so pagination only moved selection but
20190        // never changed what was displayed.
20191        let mut app = test_app();
20192        app.current_service = Service::CloudWatchAlarms;
20193        app.service_selected = true;
20194        app.mode = Mode::FilterInput;
20195        app.alarms_state.input_focus = InputFocus::Pagination;
20196        app.alarms_state.table.page_size = crate::common::PageSize::Ten;
20197
20198        // Create 25 alarms
20199        app.alarms_state.table.items = (0..25)
20200            .map(|i| crate::cw::Alarm {
20201                name: format!("alarm-{:02}", i),
20202                state: "OK".to_string(),
20203                state_updated_timestamp: String::new(),
20204                description: String::new(),
20205                metric_name: String::new(),
20206                namespace: String::new(),
20207                statistic: String::new(),
20208                period: 60,
20209                comparison_operator: String::new(),
20210                threshold: 0.0,
20211                actions_enabled: false,
20212                state_reason: String::new(),
20213                resource: String::new(),
20214                dimensions: String::new(),
20215                expression: String::new(),
20216                alarm_type: String::new(),
20217                cross_account: String::new(),
20218                ..Default::default()
20219            })
20220            .collect();
20221
20222        assert_eq!(app.alarms_state.table.selected, 0, "starts on page 1");
20223
20224        // Right arrow (PageDown) in FilterInput+Pagination must advance to page 2
20225        app.handle_action(Action::PageDown);
20226        assert_eq!(
20227            app.alarms_state.table.selected, 10,
20228            "Right arrow must advance selected to page 2"
20229        );
20230
20231        // Left arrow (PageUp) must go back
20232        app.handle_action(Action::PageUp);
20233        assert_eq!(
20234            app.alarms_state.table.selected, 0,
20235            "Left arrow must go back"
20236        );
20237
20238        // go_to_page must also work correctly
20239        app.go_to_page(3);
20240        assert_eq!(
20241            app.alarms_state.table.selected, 20,
20242            "go_to_page(3) must set selected to start of page 3"
20243        );
20244    }
20245
20246    #[test]
20247    fn test_cloudwatch_alarms_num_p_page_jump() {
20248        // <num>p must jump to that page: digits accumulate in page_input,
20249        // then p (OpenColumnSelector) applies the page jump.
20250        let mut app = test_app();
20251        app.current_service = Service::CloudWatchAlarms;
20252        app.service_selected = true;
20253        app.mode = Mode::Normal;
20254        app.alarms_state.table.page_size = crate::common::PageSize::Ten;
20255        app.alarms_state.table.items = (0..25)
20256            .map(|i| crate::cw::Alarm {
20257                name: format!("alarm-{:02}", i),
20258                state: "OK".to_string(),
20259                state_updated_timestamp: String::new(),
20260                description: String::new(),
20261                metric_name: String::new(),
20262                namespace: String::new(),
20263                statistic: String::new(),
20264                period: 60,
20265                comparison_operator: String::new(),
20266                threshold: 0.0,
20267                actions_enabled: false,
20268                state_reason: String::new(),
20269                resource: String::new(),
20270                dimensions: String::new(),
20271                expression: String::new(),
20272                alarm_type: String::new(),
20273                cross_account: String::new(),
20274                ..Default::default()
20275            })
20276            .collect();
20277
20278        // Type '2' then 'p' → jump to page 2
20279        app.handle_action(Action::FilterInput('2'));
20280        assert_eq!(app.page_input, "2");
20281        app.handle_action(Action::OpenColumnSelector);
20282        assert_eq!(
20283            app.alarms_state.table.selected, 10,
20284            "2p must jump to page 2 (selected=10)"
20285        );
20286        assert_eq!(app.page_input, "", "page_input must be cleared after jump");
20287        // mode stays Normal (not ColumnSelector) since page_input was consumed
20288        assert_ne!(
20289            app.mode,
20290            Mode::ColumnSelector,
20291            "mode must not open column selector when page_input was set"
20292        );
20293    }
20294
20295    #[test]
20296    fn test_cloudwatch_alarms_console_url_list_view() {
20297        let url = crate::cw::alarms::console_url("us-east-1", None);
20298        assert!(url.contains("alarmsV2"), "must contain alarmsV2");
20299        assert!(
20300            !url.contains("table") && !url.contains("card"),
20301            "must not embed view_mode"
20302        );
20303    }
20304
20305    #[test]
20306    fn test_cloudwatch_alarms_console_url_alarm_detail() {
20307        let url = crate::cw::alarms::console_url("us-east-1", Some("My-Alarm"));
20308        assert!(
20309            url.contains("alarmsV2:alarm/My-Alarm"),
20310            "must link directly to alarm, got: {url}"
20311        );
20312    }
20313
20314    #[test]
20315    fn test_cloudwatch_alarms_console_url_from_list_selected_alarm() {
20316        // Regression: ^o from list view with expanded row was returning the list URL
20317        // because current_alarm was None. Should use the selected alarm's name.
20318        let mut app = test_app();
20319        app.current_service = Service::CloudWatchAlarms;
20320        app.service_selected = true;
20321        app.alarms_state.current_alarm = None; // list view, not drilled in
20322        app.alarms_state.table.items = vec![
20323            crate::cw::Alarm {
20324                name: "alarm-0".to_string(),
20325                ..Default::default()
20326            },
20327            crate::cw::Alarm {
20328                name: "alarm-1".to_string(),
20329                ..Default::default()
20330            },
20331        ];
20332        app.alarms_state.table.selected = 1; // second alarm selected
20333
20334        let url = app.get_console_url();
20335        assert!(
20336            url.contains("alarmsV2:alarm/alarm-1"),
20337            "^o from list view must link to selected alarm, got: {url}"
20338        );
20339    }
20340
20341    #[test]
20342    fn test_cloudwatch_alarms_metric_math_alarm_does_not_error() {
20343        // Regression: alarms with empty statistic (metric math expressions) caused
20344        // InvalidParameterValueException when trying to call GetMetricStatistics.
20345        // The fix: skip the API call when statistic is empty.
20346        let alarm = crate::cw::Alarm {
20347            name: "math-alarm".to_string(),
20348            state: "OK".to_string(),
20349            state_updated_timestamp: String::new(),
20350            description: String::new(),
20351            metric_name: String::new(), // empty for metric math
20352            namespace: String::new(),   // empty for metric math
20353            statistic: String::new(),   // empty for metric math
20354            period: 60,
20355            comparison_operator: "GreaterThanOrEqualToThreshold".to_string(),
20356            threshold: 1.0,
20357            actions_enabled: true,
20358            state_reason: String::new(),
20359            resource: String::new(),
20360            dimensions: String::new(),
20361            expression: "SUM([m1, m2])".to_string(),
20362            alarm_type: "expression".to_string(),
20363            cross_account: String::new(),
20364            ..Default::default()
20365        };
20366        // The fix: when statistic is empty, we should NOT call get_metric_statistics
20367        assert!(
20368            alarm.statistic.is_empty(),
20369            "Metric math alarm must have empty statistic"
20370        );
20371        assert!(
20372            alarm.metric_name.is_empty(),
20373            "Metric math alarm must have empty metric_name"
20374        );
20375        // Verify the guard condition: statistic.is_empty() || metric_name.is_empty()
20376        // This is the condition checked in main.rs before calling get_metric_statistics
20377        let should_skip = alarm.statistic.is_empty() || alarm.metric_name.is_empty();
20378        assert!(
20379            should_skip,
20380            "Should skip get_metric_statistics for metric math alarms"
20381        );
20382    }
20383
20384    #[test]
20385    fn test_column_toggle_prevents_hiding_last_column() {
20386        let mut app = test_app();
20387        app.mode = Mode::Normal;
20388        app.current_service = Service::CloudWatchAlarms;
20389        app.service_selected = true;
20390
20391        // Start with only one visible column
20392        app.cw_alarm_visible_column_ids = vec!["column.cw.alarm.name"];
20393
20394        // Open column selector
20395        app.handle_action(Action::OpenColumnSelector);
20396        assert_eq!(app.mode, Mode::ColumnSelector);
20397
20398        // Select the only visible column (index 1)
20399        app.column_selector_index = 1;
20400
20401        // Try to toggle it off - should NOT remove it
20402        app.handle_action(Action::ToggleColumn);
20403
20404        // Should still have one column visible
20405        assert_eq!(app.cw_alarm_visible_column_ids.len(), 1);
20406        assert_eq!(app.cw_alarm_visible_column_ids[0], "column.cw.alarm.name");
20407    }
20408}
20409
20410#[cfg(test)]
20411mod insights_log_group_dropdown_tests {
20412    use super::*;
20413    use test_helpers::*;
20414
20415    fn setup_insights_with_log_groups(app: &mut App) {
20416        app.current_service = Service::CloudWatchInsights;
20417        app.service_selected = true;
20418        app.mode = Mode::InsightsInput;
20419        app.insights_state.insights.insights_focus = InsightsFocus::LogGroupSearch;
20420
20421        // Pre-populate log groups
20422        app.log_groups_state.log_groups.items = vec![
20423            rusticity_core::LogGroup {
20424                name: "/aws/lambda/my-function".to_string(),
20425                stored_bytes: None,
20426                retention_days: None,
20427                log_class: None,
20428                arn: None,
20429                log_group_arn: None,
20430                deletion_protection_enabled: None,
20431                creation_time: None,
20432            },
20433            rusticity_core::LogGroup {
20434                name: "/aws/containerinsights/my-cluster/application".to_string(),
20435                stored_bytes: None,
20436                retention_days: None,
20437                log_class: None,
20438                arn: None,
20439                log_group_arn: None,
20440                deletion_protection_enabled: None,
20441                creation_time: None,
20442            },
20443        ];
20444    }
20445
20446    #[test]
20447    fn test_typing_in_log_group_search_shows_dropdown() {
20448        let mut app = test_app();
20449        setup_insights_with_log_groups(&mut app);
20450
20451        // Type 'a' 'w' to search
20452        app.handle_action(Action::FilterInput('a'));
20453        app.handle_action(Action::FilterInput('w'));
20454
20455        assert!(
20456            app.insights_state.insights.show_dropdown,
20457            "Dropdown must show when log group search has matches"
20458        );
20459        assert!(
20460            !app.insights_state.insights.log_group_matches.is_empty(),
20461            "Must have matches for 'aw'"
20462        );
20463    }
20464
20465    #[test]
20466    fn test_space_toggles_highlighted_log_group_in_dropdown() {
20467        // Regression: Space in InsightsInput with LogGroupSearch focus must
20468        // toggle the highlighted dropdown entry, not type a space.
20469        let mut app = test_app();
20470        setup_insights_with_log_groups(&mut app);
20471
20472        // Type to show dropdown
20473        app.handle_action(Action::FilterInput('a'));
20474        app.handle_action(Action::FilterInput('w'));
20475
20476        assert!(app.insights_state.insights.show_dropdown);
20477        assert!(app.insights_state.insights.selected_log_groups.is_empty());
20478
20479        // Space must toggle the first/highlighted entry
20480        app.handle_action(Action::ToggleFilterCheckbox);
20481
20482        assert!(
20483            !app.insights_state.insights.selected_log_groups.is_empty(),
20484            "Space must add the highlighted log group to selected_log_groups"
20485        );
20486    }
20487
20488    #[test]
20489    fn test_space_key_dispatches_toggle_not_filter_input_in_insights_mode() {
20490        use crate::keymap::handle_key;
20491        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
20492
20493        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
20494        let action = handle_key(key, Mode::InsightsInput);
20495        assert_eq!(
20496            action,
20497            Some(Action::ToggleFilterCheckbox),
20498            "Space in InsightsInput must dispatch ToggleFilterCheckbox, not FilterInput(' ')"
20499        );
20500    }
20501
20502    #[test]
20503    fn test_down_up_arrows_navigate_dropdown() {
20504        let mut app = test_app();
20505        setup_insights_with_log_groups(&mut app);
20506        app.handle_action(Action::FilterInput('a'));
20507        app.handle_action(Action::FilterInput('w'));
20508
20509        assert_eq!(app.insights_state.insights.dropdown_selected, 0);
20510
20511        app.handle_action(Action::NextItem);
20512        assert!(
20513            app.insights_state.insights.dropdown_selected >= 1
20514                || app.insights_state.insights.log_group_matches.len() == 1,
20515            "Down arrow must advance dropdown selection"
20516        );
20517    }
20518
20519    #[test]
20520    fn test_space_in_normal_mode_toggles_dropdown_entry_not_open_space_menu() {
20521        // Regression: in Normal mode with Insights dropdown showing, Space was
20522        // opening the space menu instead of toggling the log group entry.
20523        let mut app = test_app();
20524        setup_insights_with_log_groups(&mut app);
20525        // Enter InsightsInput, type, show dropdown
20526        app.handle_action(Action::StartFilter);
20527        assert_eq!(app.mode, Mode::InsightsInput);
20528        app.handle_action(Action::FilterInput('a'));
20529        app.handle_action(Action::FilterInput('w'));
20530        assert!(app.insights_state.insights.show_dropdown);
20531
20532        // Switch back to Normal mode (simulate pressing Esc or Tab away)
20533        app.mode = Mode::Normal;
20534
20535        // Space must toggle entry, not open space menu
20536        app.handle_action(Action::OpenSpaceMenu); // Space maps to OpenSpaceMenu in Normal
20537        assert_ne!(
20538            app.mode,
20539            Mode::SpaceMenu,
20540            "Space must NOT open space menu when Insights dropdown is showing"
20541        );
20542        assert!(
20543            !app.insights_state.insights.selected_log_groups.is_empty(),
20544            "Space must toggle the highlighted log group"
20545        );
20546    }
20547
20548    #[test]
20549    fn test_enter_selects_highlighted_entry_and_closes_dropdown() {
20550        // Regression: Enter was closing the dropdown without selecting the entry.
20551        let mut app = test_app();
20552        setup_insights_with_log_groups(&mut app);
20553        app.handle_action(Action::StartFilter);
20554        app.handle_action(Action::FilterInput('a'));
20555        app.handle_action(Action::FilterInput('w'));
20556        assert!(app.insights_state.insights.show_dropdown);
20557
20558        // Enter must select highlighted entry AND close dropdown
20559        app.handle_action(Action::ApplyFilter);
20560
20561        assert!(
20562            !app.insights_state.insights.show_dropdown,
20563            "Dropdown must close after Enter"
20564        );
20565        assert!(
20566            !app.insights_state.insights.selected_log_groups.is_empty(),
20567            "Enter must select the highlighted log group before closing"
20568        );
20569    }
20570}