rusticity_term/ui/
s3.rs

1use crate::app::{App, S3Bucket as AppS3Bucket, S3Object as AppS3Object};
2use crate::common::{
3    format_bytes, format_iso_timestamp, render_scrollbar, CyclicEnum, InputFocus,
4    UTC_TIMESTAMP_WIDTH,
5};
6use crate::keymap::Mode;
7use crate::s3::{Bucket as S3Bucket, BucketColumn, Object as S3Object};
8use crate::table::TableState;
9use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
10use crate::ui::table::{format_header_cell, CURSOR_COLLAPSED, CURSOR_EXPANDED};
11use crate::ui::{
12    active_border, filter_area, format_title, get_cursor, red_text, render_tabs, rounded_block,
13    section_header, titled_block, vertical,
14};
15use ratatui::{prelude::*, widgets::*};
16use std::collections::{HashMap, HashSet};
17
18pub struct State {
19    pub buckets: TableState<S3Bucket>,
20    pub bucket_type: BucketType,
21    pub selected_row: usize,
22    pub bucket_scroll_offset: usize,
23    pub bucket_visible_rows: std::cell::Cell<usize>,
24    pub current_bucket: Option<String>,
25    pub prefix_stack: Vec<String>,
26    pub objects: Vec<S3Object>,
27    pub selected_object: usize,
28    pub object_scroll_offset: usize,
29    pub object_visible_rows: std::cell::Cell<usize>,
30    pub expanded_prefixes: HashSet<String>,
31    pub object_tab: ObjectTab,
32    pub object_filter: String,
33    pub selected_objects: HashSet<String>,
34    pub bucket_preview: HashMap<String, Vec<S3Object>>,
35    pub bucket_errors: HashMap<String, String>,
36    pub prefix_preview: HashMap<String, Vec<S3Object>>,
37    pub properties_scroll: u16,
38    pub input_focus: InputFocus,
39}
40
41impl Default for State {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl State {
48    pub fn new() -> Self {
49        Self {
50            buckets: TableState::new(),
51            bucket_type: BucketType::GeneralPurpose,
52            selected_row: 0,
53            bucket_scroll_offset: 0,
54            bucket_visible_rows: std::cell::Cell::new(30),
55            current_bucket: None,
56            prefix_stack: Vec::new(),
57            objects: Vec::new(),
58            selected_object: 0,
59            object_scroll_offset: 0,
60            object_visible_rows: std::cell::Cell::new(30),
61            expanded_prefixes: HashSet::new(),
62            object_tab: ObjectTab::Objects,
63            object_filter: String::new(),
64            selected_objects: HashSet::new(),
65            bucket_preview: HashMap::new(),
66            bucket_errors: HashMap::new(),
67            prefix_preview: HashMap::new(),
68            properties_scroll: 0,
69            input_focus: InputFocus::Filter,
70        }
71    }
72
73    pub fn calculate_total_bucket_rows(&self) -> usize {
74        fn count_nested(
75            obj: &S3Object,
76            expanded_prefixes: &HashSet<String>,
77            prefix_preview: &HashMap<String, Vec<S3Object>>,
78        ) -> usize {
79            let mut count = 0;
80            if obj.is_prefix && expanded_prefixes.contains(&obj.key) {
81                if let Some(preview) = prefix_preview.get(&obj.key) {
82                    count += preview.len();
83                    for nested_obj in preview {
84                        count += count_nested(nested_obj, expanded_prefixes, prefix_preview);
85                    }
86                }
87            }
88            count
89        }
90
91        let mut total = self.buckets.items.len();
92        for bucket in &self.buckets.items {
93            if self.expanded_prefixes.contains(&bucket.name) {
94                if self.bucket_errors.contains_key(&bucket.name) {
95                    continue;
96                }
97                if let Some(preview) = self.bucket_preview.get(&bucket.name) {
98                    total += preview.len();
99                    for obj in preview {
100                        total += count_nested(obj, &self.expanded_prefixes, &self.prefix_preview);
101                    }
102                }
103            }
104        }
105        total
106    }
107
108    pub fn calculate_total_object_rows(&self) -> usize {
109        fn count_nested(
110            obj: &S3Object,
111            expanded_prefixes: &HashSet<String>,
112            prefix_preview: &HashMap<String, Vec<S3Object>>,
113        ) -> usize {
114            let mut count = 0;
115            if obj.is_prefix && expanded_prefixes.contains(&obj.key) {
116                if let Some(preview) = prefix_preview.get(&obj.key) {
117                    count += preview.len();
118                    for nested_obj in preview {
119                        count += count_nested(nested_obj, expanded_prefixes, prefix_preview);
120                    }
121                } else {
122                    count += 1;
123                }
124            }
125            count
126        }
127
128        let mut total = self.objects.len();
129        for obj in &self.objects {
130            total += count_nested(obj, &self.expanded_prefixes, &self.prefix_preview);
131        }
132        total
133    }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq)]
137pub enum BucketType {
138    GeneralPurpose,
139    Directory,
140}
141
142impl CyclicEnum for BucketType {
143    const ALL: &'static [Self] = &[Self::GeneralPurpose, Self::Directory];
144}
145
146impl BucketType {
147    pub fn name(&self) -> &'static str {
148        match self {
149            BucketType::GeneralPurpose => "General purpose buckets (All AWS Regions)",
150            BucketType::Directory => "Directory buckets",
151        }
152    }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq)]
156pub enum ObjectTab {
157    Objects,
158    Metadata,
159    Properties,
160    Permissions,
161    Metrics,
162    Management,
163    AccessPoints,
164}
165
166impl CyclicEnum for ObjectTab {
167    const ALL: &'static [Self] = &[Self::Objects, Self::Properties];
168}
169
170impl ObjectTab {
171    pub fn name(&self) -> &'static str {
172        match self {
173            ObjectTab::Objects => "Objects",
174            ObjectTab::Metadata => "Metadata",
175            ObjectTab::Properties => "Properties",
176            ObjectTab::Permissions => "Permissions",
177            ObjectTab::Metrics => "Metrics",
178            ObjectTab::Management => "Management",
179            ObjectTab::AccessPoints => "Access Points",
180        }
181    }
182
183    pub fn all() -> Vec<ObjectTab> {
184        vec![ObjectTab::Objects, ObjectTab::Properties]
185    }
186}
187
188pub fn calculate_filtered_bucket_rows(app: &App) -> usize {
189    fn count_nested(
190        obj: &AppS3Object,
191        expanded_prefixes: &std::collections::HashSet<String>,
192        prefix_preview: &std::collections::HashMap<String, Vec<AppS3Object>>,
193    ) -> usize {
194        let mut count = 0;
195        if obj.is_prefix && expanded_prefixes.contains(&obj.key) {
196            if let Some(preview) = prefix_preview.get(&obj.key) {
197                count += preview.len();
198                for nested_obj in preview {
199                    count += count_nested(nested_obj, expanded_prefixes, prefix_preview);
200                }
201            }
202        }
203        count
204    }
205
206    let filtered_buckets: Vec<_> = app
207        .s3_state
208        .buckets
209        .items
210        .iter()
211        .filter(|b| {
212            if app.s3_state.buckets.filter.is_empty() {
213                true
214            } else {
215                b.name
216                    .to_lowercase()
217                    .contains(&app.s3_state.buckets.filter.to_lowercase())
218            }
219        })
220        .collect();
221
222    let mut total = filtered_buckets.len();
223    for bucket in filtered_buckets {
224        if app.s3_state.expanded_prefixes.contains(&bucket.name) {
225            if let Some(error) = app.s3_state.bucket_errors.get(&bucket.name) {
226                let max_width = 120;
227                let error_row_count = if error.len() > max_width {
228                    error.len().div_ceil(max_width)
229                } else {
230                    1
231                };
232                total += error_row_count;
233                continue;
234            }
235            if let Some(preview) = app.s3_state.bucket_preview.get(&bucket.name) {
236                total += preview.len();
237                for obj in preview {
238                    total += count_nested(
239                        obj,
240                        &app.s3_state.expanded_prefixes,
241                        &app.s3_state.prefix_preview,
242                    );
243                }
244            }
245        }
246    }
247    total
248}
249
250pub fn calculate_total_bucket_rows(app: &App) -> usize {
251    fn count_nested(
252        obj: &AppS3Object,
253        expanded_prefixes: &std::collections::HashSet<String>,
254        prefix_preview: &std::collections::HashMap<String, Vec<AppS3Object>>,
255    ) -> usize {
256        let mut count = 0;
257        if obj.is_prefix && expanded_prefixes.contains(&obj.key) {
258            if let Some(preview) = prefix_preview.get(&obj.key) {
259                count += preview.len();
260                for nested_obj in preview {
261                    count += count_nested(nested_obj, expanded_prefixes, prefix_preview);
262                }
263            }
264        }
265        count
266    }
267
268    let mut total = app.s3_state.buckets.items.len();
269    for bucket in &app.s3_state.buckets.items {
270        if app.s3_state.expanded_prefixes.contains(&bucket.name) {
271            if let Some(error) = app.s3_state.bucket_errors.get(&bucket.name) {
272                // Count error message rows (split by max width)
273                let max_width = 120;
274                let error_row_count = if error.len() > max_width {
275                    error.len().div_ceil(max_width)
276                } else {
277                    1
278                };
279                total += error_row_count;
280                continue;
281            }
282            if let Some(preview) = app.s3_state.bucket_preview.get(&bucket.name) {
283                total += preview.len();
284                for obj in preview {
285                    total += count_nested(
286                        obj,
287                        &app.s3_state.expanded_prefixes,
288                        &app.s3_state.prefix_preview,
289                    );
290                }
291            }
292        }
293    }
294    total
295}
296
297pub fn calculate_total_object_rows(app: &App) -> usize {
298    fn count_nested(
299        obj: &AppS3Object,
300        expanded_prefixes: &std::collections::HashSet<String>,
301        prefix_preview: &std::collections::HashMap<String, Vec<AppS3Object>>,
302    ) -> usize {
303        let mut count = 0;
304        if obj.is_prefix && expanded_prefixes.contains(&obj.key) {
305            if let Some(preview) = prefix_preview.get(&obj.key) {
306                count += preview.len();
307                for nested_obj in preview {
308                    count += count_nested(nested_obj, expanded_prefixes, prefix_preview);
309                }
310            } else {
311                count += 1;
312            }
313        }
314        count
315    }
316
317    let mut total = app.s3_state.objects.len();
318    for obj in &app.s3_state.objects {
319        total += count_nested(
320            obj,
321            &app.s3_state.expanded_prefixes,
322            &app.s3_state.prefix_preview,
323        );
324    }
325    total
326}
327
328pub fn render_buckets(frame: &mut Frame, app: &App, area: Rect) {
329    frame.render_widget(Clear, area);
330
331    if app.s3_state.current_bucket.is_some() {
332        render_objects(frame, app, area);
333    } else {
334        render_bucket_list(frame, app, area);
335    }
336}
337
338// S3 functions extracted from mod.rs - content will be added via file append
339fn render_bucket_list(frame: &mut Frame, app: &App, area: Rect) {
340    frame.render_widget(Clear, area);
341
342    let chunks = vertical(
343        [
344            Constraint::Length(1), // Tabs
345            Constraint::Length(3), // Filter (1 line + borders)
346            Constraint::Min(0),    // Table
347        ],
348        area,
349    );
350
351    // Update visible rows based on actual area (subtract borders + header)
352    let visible_rows = chunks[2].height.saturating_sub(3) as usize;
353    app.s3_state.bucket_visible_rows.set(visible_rows);
354
355    // Tabs
356    let tabs: Vec<(&str, BucketType)> = BucketType::ALL
357        .iter()
358        .map(|tab| (tab.name(), *tab))
359        .collect();
360    render_tabs(frame, chunks[0], &tabs, &app.s3_state.bucket_type);
361
362    // Filter buckets first
363    let all_filtered_buckets: Vec<_> = if app.s3_state.bucket_type == BucketType::GeneralPurpose {
364        app.s3_state
365            .buckets
366            .items
367            .iter()
368            .enumerate()
369            .filter(|(_, b)| {
370                if app.s3_state.buckets.filter.is_empty() {
371                    true
372                } else {
373                    b.name
374                        .to_lowercase()
375                        .contains(&app.s3_state.buckets.filter.to_lowercase())
376                }
377            })
378            .collect()
379    } else {
380        // Directory buckets - not supported yet
381        Vec::new()
382    };
383
384    // Paginate the filtered buckets
385    // Note: selected_row is a global row index (including expanded children)
386    // We need to find which bucket it corresponds to, then paginate from there
387    let page_size = app.s3_state.buckets.page_size.value();
388
389    // Find which bucket contains the selected row
390    let mut current_bucket_idx = 0;
391    let mut row_count = 0;
392    for (idx, (_, bucket)) in all_filtered_buckets.iter().enumerate() {
393        if row_count == app.s3_state.selected_row {
394            current_bucket_idx = idx;
395            break;
396        }
397        if row_count > app.s3_state.selected_row {
398            break;
399        }
400        current_bucket_idx = idx;
401        row_count += 1; // The bucket itself
402        if app.s3_state.expanded_prefixes.contains(&bucket.name) {
403            if let Some(preview) = app.s3_state.bucket_preview.get(&bucket.name) {
404                fn count_children(
405                    objects: &[S3Object],
406                    expanded: &HashSet<String>,
407                    previews: &HashMap<String, Vec<S3Object>>,
408                ) -> usize {
409                    let mut count = objects.len();
410                    for obj in objects {
411                        if obj.is_prefix && expanded.contains(&obj.key) {
412                            if let Some(nested) = previews.get(&obj.key) {
413                                count += count_children(nested, expanded, previews);
414                            }
415                        }
416                    }
417                    count
418                }
419                row_count += count_children(
420                    preview,
421                    &app.s3_state.expanded_prefixes,
422                    &app.s3_state.prefix_preview,
423                );
424            }
425        }
426    }
427
428    // Calculate page based on bucket index
429    let current_page = current_bucket_idx / page_size;
430    let start_idx = current_page * page_size;
431    let end_idx = (start_idx + page_size).min(all_filtered_buckets.len());
432    let filtered_buckets: Vec<_> = all_filtered_buckets[start_idx..end_idx].to_vec();
433
434    // Calculate pagination based on filtered buckets
435    let total_pages = all_filtered_buckets.len().div_ceil(page_size);
436    let pagination = crate::common::render_pagination_text(current_page, total_pages);
437
438    // Render filter pane with pagination
439    render_simple_filter(
440        frame,
441        chunks[1],
442        SimpleFilterConfig {
443            filter_text: &app.s3_state.buckets.filter,
444            placeholder: "Find buckets by name",
445            pagination: &pagination,
446            mode: app.mode,
447            is_input_focused: app.s3_state.input_focus == InputFocus::Filter,
448            is_pagination_focused: app.s3_state.input_focus == InputFocus::Pagination,
449        },
450    );
451
452    let count = all_filtered_buckets.len();
453    let bucket_type_name = match app.s3_state.bucket_type {
454        BucketType::GeneralPurpose => "General purpose buckets",
455        BucketType::Directory => "Directory buckets",
456    };
457    let title = format!("{} ({})", bucket_type_name, count);
458
459    let header_cells: Vec<Cell> = app
460        .s3_bucket_visible_column_ids
461        .iter()
462        .enumerate()
463        .filter_map(|(i, col_id)| {
464            BucketColumn::from_id(col_id).map(|col| {
465                let name = format_header_cell(&col.name(), i);
466                Cell::from(name).style(Style::default().add_modifier(Modifier::BOLD))
467            })
468        })
469        .collect();
470    let header = Row::new(header_cells)
471        .style(Style::default().bg(Color::White).fg(Color::Black))
472        .height(1);
473
474    // Calculate max widths from content
475    let mut max_name_width = "Name".len() + 2; // +2 for "  " padding in first column
476    let mut max_region_width = "Region".len();
477    let mut max_date_width = "Creation date".len();
478
479    for (_idx, bucket) in &filtered_buckets {
480        let name_len = format!("{} 🪣 {}", CURSOR_COLLAPSED, bucket.name).len();
481        max_name_width = max_name_width.max(name_len);
482        let region_display = if bucket.region.is_empty() {
483            "-"
484        } else {
485            &bucket.region
486        };
487        max_region_width = max_region_width.max(region_display.len());
488        max_date_width = max_date_width.max(25); // UTC timestamp width
489
490        if app.s3_state.expanded_prefixes.contains(&bucket.name) {
491            if let Some(preview) = app.s3_state.bucket_preview.get(&bucket.name) {
492                for obj in preview {
493                    let obj_len = format!(
494                        "  ▶ {} {}",
495                        if obj.is_prefix { "📁" } else { "📄" },
496                        obj.key
497                    )
498                    .len();
499                    max_name_width = max_name_width.max(obj_len);
500
501                    if obj.is_prefix && app.s3_state.expanded_prefixes.contains(&obj.key) {
502                        if let Some(nested) = app.s3_state.prefix_preview.get(&obj.key) {
503                            for nested_obj in nested {
504                                let nested_len = format!(
505                                    "      {} {}",
506                                    if nested_obj.is_prefix { "📁" } else { "📄" },
507                                    nested_obj.key
508                                )
509                                .len();
510                                max_name_width = max_name_width.max(nested_len);
511                            }
512                        }
513                    }
514                }
515            }
516        }
517    }
518
519    // Cap at reasonable maximums
520    max_name_width = max_name_width.min(150);
521
522    // Calculate the row index of the first bucket on this page
523    let mut first_bucket_row_idx = 0;
524    for i in 0..start_idx {
525        if let Some((_, b)) = all_filtered_buckets.get(i) {
526            first_bucket_row_idx += 1; // The bucket itself
527            if app.s3_state.expanded_prefixes.contains(&b.name) {
528                if let Some(preview) = app.s3_state.bucket_preview.get(&b.name) {
529                    fn count_children(
530                        objects: &[S3Object],
531                        expanded: &HashSet<String>,
532                        previews: &HashMap<String, Vec<S3Object>>,
533                    ) -> usize {
534                        let mut count = objects.len();
535                        for obj in objects {
536                            if obj.is_prefix && expanded.contains(&obj.key) {
537                                if let Some(nested) = previews.get(&obj.key) {
538                                    count += count_children(nested, expanded, previews);
539                                }
540                            }
541                        }
542                        count
543                    }
544                    first_bucket_row_idx += count_children(
545                        preview,
546                        &app.s3_state.expanded_prefixes,
547                        &app.s3_state.prefix_preview,
548                    );
549                }
550            }
551        }
552    }
553
554    let rows: Vec<Row> = filtered_buckets
555        .iter()
556        .enumerate()
557        .flat_map(|(bucket_idx, (_orig_idx, bucket))| {
558            let is_expanded = app.s3_state.expanded_prefixes.contains(&bucket.name);
559            let expand_indicator = if is_expanded {
560                format!("{} ", CURSOR_EXPANDED)
561            } else {
562                format!("{} ", CURSOR_COLLAPSED)
563            };
564
565            // Format date as YYYY-MM-DD HH:MM:SS (UTC)
566            let formatted_date = if bucket.creation_date.contains('T') {
567                // Parse ISO 8601 format: 2025-05-07T12:34:47Z
568                let parts: Vec<&str> = bucket.creation_date.split('T').collect();
569                if parts.len() == 2 {
570                    let date = parts[0];
571                    let time = parts[1]
572                        .trim_end_matches('Z')
573                        .split('.')
574                        .next()
575                        .unwrap_or(parts[1]);
576                    format!("{} {} (UTC)", date, time)
577                } else {
578                    bucket.creation_date.clone()
579                }
580            } else {
581                bucket.creation_date.clone()
582            };
583
584            // Calculate row index for this bucket
585            fn count_expanded_children(
586                objects: &[S3Object],
587                expanded_prefixes: &std::collections::HashSet<String>,
588                prefix_preview: &std::collections::HashMap<String, Vec<S3Object>>,
589            ) -> usize {
590                let mut count = objects.len();
591                for obj in objects {
592                    if obj.is_prefix && expanded_prefixes.contains(&obj.key) {
593                        if let Some(nested) = prefix_preview.get(&obj.key) {
594                            count +=
595                                count_expanded_children(nested, expanded_prefixes, prefix_preview);
596                        }
597                    }
598                }
599                count
600            }
601
602            // Calculate row index - need to count all buckets before this one (including previous pages)
603            let mut row_idx = 0;
604            // Count all buckets on previous pages
605            for i in 0..start_idx {
606                if let Some((_, b)) = all_filtered_buckets.get(i) {
607                    row_idx += 1; // The bucket itself
608                    if app.s3_state.expanded_prefixes.contains(&b.name) {
609                        if let Some(preview) = app.s3_state.bucket_preview.get(&b.name) {
610                            row_idx += count_expanded_children(
611                                preview,
612                                &app.s3_state.expanded_prefixes,
613                                &app.s3_state.prefix_preview,
614                            );
615                        }
616                    }
617                }
618            }
619            // Count buckets on current page before this one
620            for i in 0..bucket_idx {
621                if let Some((_, b)) = filtered_buckets.get(i) {
622                    row_idx += 1; // The bucket itself
623                    if app.s3_state.expanded_prefixes.contains(&b.name) {
624                        if let Some(preview) = app.s3_state.bucket_preview.get(&b.name) {
625                            row_idx += count_expanded_children(
626                                preview,
627                                &app.s3_state.expanded_prefixes,
628                                &app.s3_state.prefix_preview,
629                            );
630                        }
631                    }
632                }
633            }
634
635            let style = if row_idx == app.s3_state.selected_row {
636                Style::default().bg(Color::DarkGray)
637            } else {
638                Style::default()
639            };
640
641            let cells: Vec<Cell> = app
642                .s3_bucket_visible_column_ids
643                .iter()
644                .enumerate()
645                .filter_map(|(i, col_id)| {
646                    BucketColumn::from_id(col_id).map(|col| {
647                        let content = match col {
648                            BucketColumn::Name => {
649                                format!("{}🪣 {}", expand_indicator, bucket.name)
650                            }
651                            BucketColumn::Region => bucket.region.clone(),
652                            BucketColumn::CreationDate => formatted_date.clone(),
653                        };
654                        let cell_content = if i > 0 {
655                            format!("⋮ {}", content)
656                        } else {
657                            content
658                        };
659                        Cell::from(cell_content)
660                    })
661                })
662                .collect();
663
664            let mut result = vec![Row::new(cells).height(1).style(style)];
665            let mut child_row_idx = row_idx + 1;
666
667            if is_expanded {
668                if let Some(error) = app.s3_state.bucket_errors.get(&bucket.name) {
669                    // Show error message in expanded rows (non-selectable)
670                    // Split error into multiple lines if needed
671                    let max_width = 120;
672                    let error_lines: Vec<String> = if error.len() > max_width {
673                        error
674                            .as_bytes()
675                            .chunks(max_width)
676                            .map(|chunk| String::from_utf8_lossy(chunk).to_string())
677                            .collect()
678                    } else {
679                        vec![error.clone()]
680                    };
681
682                    for (line_idx, error_line) in error_lines.iter().enumerate() {
683                        let error_cells: Vec<Cell> = app
684                            .s3_bucket_visible_column_ids
685                            .iter()
686                            .enumerate()
687                            .map(|(i, _col)| {
688                                if i == 0 {
689                                    if line_idx == 0 {
690                                        Cell::from(format!("  ⚠️  {}", error_line))
691                                            .style(red_text())
692                                    } else {
693                                        Cell::from(format!("     {}", error_line)).style(red_text())
694                                    }
695                                } else {
696                                    Cell::from("")
697                                }
698                            })
699                            .collect();
700                        result.push(Row::new(error_cells).height(1));
701                    }
702                    // Don't increment child_row_idx for error rows - they're not selectable
703                } else if let Some(preview) = app.s3_state.bucket_preview.get(&bucket.name) {
704                    // Recursive function to render objects at any depth
705                    fn render_objects_recursive<'a>(
706                        objects: &'a [S3Object],
707                        app: &'a App,
708                        child_row_idx: &mut usize,
709                        result: &mut Vec<Row<'a>>,
710                        parent_key: &str,
711                        is_last: &[bool],
712                    ) {
713                        for (idx, obj) in objects.iter().enumerate() {
714                            let is_last_item = idx == objects.len() - 1;
715                            let obj_is_expanded = app.s3_state.expanded_prefixes.contains(&obj.key);
716
717                            // Build prefix with tree characters
718                            let mut prefix = String::new();
719                            for &last in is_last.iter() {
720                                prefix.push_str(if last { "  " } else { "│ " });
721                            }
722
723                            let tree_char = if is_last_item { "╰─" } else { "├─" };
724                            let expand_char = if obj.is_prefix {
725                                if obj_is_expanded {
726                                    CURSOR_EXPANDED
727                                } else {
728                                    CURSOR_COLLAPSED
729                                }
730                            } else {
731                                ""
732                            };
733
734                            let icon = if obj.is_prefix { "📁" } else { "📄" };
735                            let display_key = obj.key.strip_prefix(parent_key).unwrap_or(&obj.key);
736
737                            let child_style = if *child_row_idx == app.s3_state.selected_row {
738                                Style::default().bg(Color::DarkGray)
739                            } else {
740                                Style::default()
741                            };
742
743                            let formatted_date = format_iso_timestamp(&obj.last_modified);
744
745                            let child_cells: Vec<Cell> = app
746                                .s3_bucket_visible_column_ids
747                                .iter()
748                                .enumerate()
749                                .filter_map(|(i, col_id)| {
750                                    BucketColumn::from_id(col_id).map(|col| {
751                                        let content = match col {
752                                            BucketColumn::Name => format!(
753                                                "{}{}{} {} {}",
754                                                prefix, tree_char, expand_char, icon, display_key
755                                            ),
756                                            BucketColumn::Region => String::new(),
757                                            BucketColumn::CreationDate => formatted_date.clone(),
758                                        };
759                                        if i > 0 {
760                                            Cell::from(format!("⋮ {}", content))
761                                        } else {
762                                            Cell::from(content)
763                                        }
764                                    })
765                                })
766                                .collect();
767                            result.push(Row::new(child_cells).style(child_style));
768                            *child_row_idx += 1;
769
770                            // Recursively render nested items if expanded
771                            if obj.is_prefix && obj_is_expanded {
772                                if let Some(nested_preview) =
773                                    app.s3_state.prefix_preview.get(&obj.key)
774                                {
775                                    let mut new_is_last = is_last.to_vec();
776                                    new_is_last.push(is_last_item);
777                                    render_objects_recursive(
778                                        nested_preview,
779                                        app,
780                                        child_row_idx,
781                                        result,
782                                        &obj.key,
783                                        &new_is_last,
784                                    );
785                                }
786                            }
787                        }
788                    }
789
790                    render_objects_recursive(
791                        preview,
792                        app,
793                        &mut child_row_idx,
794                        &mut result,
795                        "",
796                        &[],
797                    );
798                }
799            }
800
801            result
802        })
803        .skip(
804            app.s3_state
805                .bucket_scroll_offset
806                .saturating_sub(first_bucket_row_idx),
807        )
808        .take(app.s3_state.bucket_visible_rows.get())
809        .collect();
810
811    let widths: Vec<Constraint> = app
812        .s3_bucket_visible_column_ids
813        .iter()
814        .enumerate()
815        .filter_map(|(i, col_id)| {
816            BucketColumn::from_id(col_id).map(|col| {
817                let base_width = match col {
818                    BucketColumn::Name => max_name_width,
819                    BucketColumn::Region => max_region_width,
820                    BucketColumn::CreationDate => max_date_width,
821                };
822                // Add 2 for "⋮ " separator on columns after the first
823                let width = if i > 0 { base_width + 2 } else { base_width };
824                Constraint::Length(width as u16)
825            })
826        })
827        .collect();
828
829    let is_active = app.mode != Mode::ColumnSelector;
830    let border_color = if is_active {
831        Color::Green
832    } else {
833        Color::White
834    };
835
836    let table = Table::new(rows, widths)
837        .header(header)
838        .column_spacing(1)
839        .block(titled_block(title).border_style(Style::default().fg(border_color)));
840
841    frame.render_widget(table, chunks[2]);
842
843    // Render scrollbar if content exceeds visible area
844    let total_rows = app.s3_state.calculate_total_bucket_rows();
845    let visible_rows = chunks[2].height.saturating_sub(3) as usize; // Subtract borders and header
846    if total_rows > visible_rows {
847        render_scrollbar(
848            frame,
849            chunks[2].inner(Margin {
850                vertical: 1,
851                horizontal: 0,
852            }),
853            total_rows,
854            app.s3_state.selected_row,
855        );
856    }
857}
858
859fn render_objects(frame: &mut Frame, app: &App, area: Rect) {
860    let show_filter = app.s3_state.object_tab == ObjectTab::Objects;
861
862    let chunks = if show_filter {
863        vertical(
864            [
865                Constraint::Length(1), // Tabs
866                Constraint::Length(3), // Filter (1 line + borders)
867                Constraint::Min(0),    // Content
868            ],
869            area,
870        )
871    } else {
872        vertical(
873            [
874                Constraint::Length(1), // Tabs
875                Constraint::Min(0),    // Content (no filter)
876            ],
877            area,
878        )
879    };
880
881    // Update visible rows based on actual content area
882    let content_area_idx = if show_filter { 2 } else { 1 };
883    let visible_rows = chunks[content_area_idx].height.saturating_sub(3) as usize;
884    app.s3_state.object_visible_rows.set(visible_rows);
885
886    // Tabs
887    let available_tabs = if app.s3_state.prefix_stack.is_empty() {
888        // At bucket root - show all tabs
889        ObjectTab::all()
890    } else {
891        // In a prefix - only Objects and Properties
892        vec![ObjectTab::Objects, ObjectTab::Properties]
893    };
894
895    let tab_tuples: Vec<(&str, ObjectTab)> = available_tabs
896        .iter()
897        .map(|tab| (tab.name(), *tab))
898        .collect();
899
900    frame.render_widget(Clear, chunks[0]);
901    render_tabs(frame, chunks[0], &tab_tuples, &app.s3_state.object_tab);
902
903    // Filter (only for Objects tab)
904    if app.s3_state.object_tab == ObjectTab::Objects {
905        let cursor = get_cursor(app.mode == Mode::FilterInput);
906        let filter_text = if app.s3_state.object_filter.is_empty() && app.mode != Mode::FilterInput
907        {
908            vec![
909                Span::styled(
910                    "Find objects by prefix",
911                    Style::default().fg(Color::DarkGray),
912                ),
913                Span::styled(cursor, Style::default().fg(Color::Yellow)),
914            ]
915        } else {
916            vec![
917                Span::raw(&app.s3_state.object_filter),
918                Span::styled(cursor, Style::default().fg(Color::Yellow)),
919            ]
920        };
921        let filter = filter_area(filter_text, app.mode == Mode::FilterInput);
922        frame.render_widget(filter, chunks[1]);
923    }
924
925    // Render content based on selected tab
926    let content_idx = if show_filter { 2 } else { 1 };
927    match app.s3_state.object_tab {
928        ObjectTab::Objects => render_objects_table(frame, app, chunks[content_idx]),
929        ObjectTab::Properties => render_bucket_properties(frame, app, chunks[content_idx]),
930        _ => {
931            // Placeholder for other tabs
932            let placeholder =
933                Paragraph::new(format!("{} - Coming soon", app.s3_state.object_tab.name()))
934                    .block(rounded_block().border_style(active_border()))
935                    .style(Style::default().fg(Color::Gray));
936            frame.render_widget(placeholder, chunks[content_idx]);
937        }
938    }
939}
940
941fn render_objects_table(frame: &mut Frame, app: &App, area: Rect) {
942    // Filter objects by prefix
943    let filtered_objects: Vec<_> = app
944        .s3_state
945        .objects
946        .iter()
947        .enumerate()
948        .filter(|(_, obj)| {
949            if app.s3_state.object_filter.is_empty() {
950                true
951            } else {
952                let name = obj.key.trim_start_matches(
953                    &app.s3_state
954                        .prefix_stack
955                        .last()
956                        .cloned()
957                        .unwrap_or_default(),
958                );
959                name.to_lowercase()
960                    .contains(&app.s3_state.object_filter.to_lowercase())
961            }
962        })
963        .collect();
964
965    let count = filtered_objects.len();
966    let title = format_title(&format!("Objects ({})", count));
967
968    let columns = ["Name", "Type", "Last modified", "Size", "Storage class"];
969    let header_cells: Vec<Cell> = columns
970        .iter()
971        .enumerate()
972        .map(|(i, name)| {
973            Cell::from(format_header_cell(name, i))
974                .style(Style::default().add_modifier(Modifier::BOLD))
975        })
976        .collect();
977    let header = Row::new(header_cells)
978        .style(Style::default().bg(Color::White).fg(Color::Black))
979        .height(1)
980        .bottom_margin(0);
981
982    // Calculate max name width
983    let max_name_width = filtered_objects
984        .iter()
985        .map(|(_, obj)| {
986            let name = obj.key.trim_start_matches(
987                &app.s3_state
988                    .prefix_stack
989                    .last()
990                    .cloned()
991                    .unwrap_or_default(),
992            );
993            name.len() + 4 // +4 for icon and expand indicator
994        })
995        .max()
996        .unwrap_or(30)
997        .max(30) as u16;
998
999    let rows: Vec<Row> = filtered_objects
1000        .iter()
1001        .flat_map(|(idx, obj)| {
1002            let icon = if obj.is_prefix { "📁" } else { "📄" };
1003
1004            // Add expand indicator for prefixes
1005            let expand_indicator = if obj.is_prefix {
1006                if app.s3_state.expanded_prefixes.contains(&obj.key) {
1007                    format!("{} ", CURSOR_EXPANDED)
1008                } else {
1009                    format!("{} ", CURSOR_COLLAPSED)
1010                }
1011            } else {
1012                String::new()
1013            };
1014
1015            let name = obj.key.trim_start_matches(
1016                &app.s3_state
1017                    .prefix_stack
1018                    .last()
1019                    .cloned()
1020                    .unwrap_or_default(),
1021            );
1022            let display_name = format!("{}{} {}", expand_indicator, icon, name);
1023            let obj_type = if obj.is_prefix { "Folder" } else { "File" };
1024            let size = if obj.is_prefix {
1025                String::new()
1026            } else {
1027                format_bytes(obj.size)
1028            };
1029
1030            // Format datetime with (UTC)
1031            let datetime = format_iso_timestamp(&obj.last_modified);
1032
1033            // Format storage class
1034            let storage = if obj.storage_class.is_empty() {
1035                String::new()
1036            } else {
1037                obj.storage_class
1038                    .chars()
1039                    .next()
1040                    .unwrap()
1041                    .to_uppercase()
1042                    .to_string()
1043                    + &obj.storage_class[1..].to_lowercase()
1044            };
1045
1046            // Calculate row index including nested items
1047            let mut row_idx = *idx;
1048            for i in 0..*idx {
1049                if let Some(prev_obj) = app.s3_state.objects.get(i) {
1050                    if prev_obj.is_prefix && app.s3_state.expanded_prefixes.contains(&prev_obj.key)
1051                    {
1052                        if let Some(preview) = app.s3_state.prefix_preview.get(&prev_obj.key) {
1053                            row_idx += preview.len();
1054                        }
1055                    }
1056                }
1057            }
1058
1059            let style = if row_idx == app.s3_state.selected_object {
1060                Style::default().bg(Color::DarkGray)
1061            } else {
1062                Style::default()
1063            };
1064
1065            let mut result = vec![Row::new(vec![
1066                Cell::from(display_name),
1067                Cell::from(format!("⋮ {}", obj_type)),
1068                Cell::from(format!("⋮ {}", datetime)),
1069                Cell::from(format!("⋮ {}", size)),
1070                Cell::from(format!("⋮ {}", storage)),
1071            ])
1072            .style(style)];
1073
1074            let mut child_row_idx = row_idx + 1;
1075
1076            if obj.is_prefix && app.s3_state.expanded_prefixes.contains(&obj.key) {
1077                if let Some(preview) = app.s3_state.prefix_preview.get(&obj.key) {
1078                    // Recursive function to render nested objects
1079                    fn render_nested_objects<'a>(
1080                        objects: &'a [S3Object],
1081                        app: &'a App,
1082                        child_row_idx: &mut usize,
1083                        result: &mut Vec<Row<'a>>,
1084                        parent_key: &str,
1085                        is_last: &[bool],
1086                    ) {
1087                        for (child_idx, preview_obj) in objects.iter().enumerate() {
1088                            let is_last_child = child_idx == objects.len() - 1;
1089                            let obj_is_expanded =
1090                                app.s3_state.expanded_prefixes.contains(&preview_obj.key);
1091
1092                            // Build prefix with tree characters
1093                            let mut prefix = String::new();
1094                            for &last in is_last.iter() {
1095                                prefix.push_str(if last { "  " } else { "│ " });
1096                            }
1097
1098                            let tree_char = if is_last_child { "╰─" } else { "├─" };
1099                            let child_expand = if preview_obj.is_prefix {
1100                                if obj_is_expanded {
1101                                    CURSOR_EXPANDED
1102                                } else {
1103                                    CURSOR_COLLAPSED
1104                                }
1105                            } else {
1106                                ""
1107                            };
1108                            let child_icon = if preview_obj.is_prefix {
1109                                "📁"
1110                            } else {
1111                                "📄"
1112                            };
1113                            let child_name = preview_obj
1114                                .key
1115                                .strip_prefix(parent_key)
1116                                .unwrap_or(&preview_obj.key);
1117
1118                            let child_type = if preview_obj.is_prefix {
1119                                "Folder"
1120                            } else {
1121                                "File"
1122                            };
1123                            let child_size = if preview_obj.is_prefix {
1124                                String::new()
1125                            } else {
1126                                format_bytes(preview_obj.size)
1127                            };
1128                            let child_datetime = format_iso_timestamp(&preview_obj.last_modified);
1129                            let child_storage = if preview_obj.storage_class.is_empty() {
1130                                String::new()
1131                            } else {
1132                                preview_obj
1133                                    .storage_class
1134                                    .chars()
1135                                    .next()
1136                                    .unwrap()
1137                                    .to_uppercase()
1138                                    .to_string()
1139                                    + &preview_obj.storage_class[1..].to_lowercase()
1140                            };
1141
1142                            let child_style = if *child_row_idx == app.s3_state.selected_object {
1143                                Style::default().bg(Color::DarkGray)
1144                            } else {
1145                                Style::default()
1146                            };
1147
1148                            result.push(
1149                                Row::new(vec![
1150                                    Cell::from(format!(
1151                                        "{}{}{} {} {}",
1152                                        prefix, tree_char, child_expand, child_icon, child_name
1153                                    )),
1154                                    Cell::from(format!("⋮ {}", child_type)),
1155                                    Cell::from(format!("⋮ {}", child_datetime)),
1156                                    Cell::from(format!("⋮ {}", child_size)),
1157                                    Cell::from(format!("⋮ {}", child_storage)),
1158                                ])
1159                                .style(child_style),
1160                            );
1161                            *child_row_idx += 1;
1162
1163                            // Recursively render nested children
1164                            if preview_obj.is_prefix && obj_is_expanded {
1165                                if let Some(nested_preview) =
1166                                    app.s3_state.prefix_preview.get(&preview_obj.key)
1167                                {
1168                                    let mut new_is_last = is_last.to_vec();
1169                                    new_is_last.push(is_last_child);
1170                                    render_nested_objects(
1171                                        nested_preview,
1172                                        app,
1173                                        child_row_idx,
1174                                        result,
1175                                        &preview_obj.key,
1176                                        &new_is_last,
1177                                    );
1178                                }
1179                            }
1180                        }
1181                    }
1182
1183                    render_nested_objects(
1184                        preview,
1185                        app,
1186                        &mut child_row_idx,
1187                        &mut result,
1188                        &obj.key,
1189                        &[],
1190                    );
1191                }
1192            }
1193
1194            result
1195        })
1196        .skip(app.s3_state.object_scroll_offset)
1197        .take(app.s3_state.object_visible_rows.get())
1198        .collect();
1199
1200    let table = Table::new(
1201        rows,
1202        vec![
1203            Constraint::Length(max_name_width),
1204            Constraint::Length(10),
1205            Constraint::Length(UTC_TIMESTAMP_WIDTH),
1206            Constraint::Length(12),
1207            Constraint::Length(15),
1208        ],
1209    )
1210    .header(header)
1211    .column_spacing(1)
1212    .block(rounded_block().title(title).border_style(active_border()));
1213
1214    frame.render_widget(table, area);
1215
1216    // Render scrollbar if content exceeds visible area
1217    let total_rows = app.s3_state.calculate_total_object_rows();
1218    let visible_rows = area.height.saturating_sub(3) as usize;
1219    if total_rows > visible_rows {
1220        render_scrollbar(
1221            frame,
1222            area.inner(Margin {
1223                vertical: 1,
1224                horizontal: 0,
1225            }),
1226            total_rows,
1227            app.s3_state.selected_object,
1228        );
1229    }
1230}
1231
1232fn render_bucket_properties(frame: &mut Frame, app: &App, area: Rect) {
1233    let bucket_name = app.s3_state.current_bucket.as_ref().unwrap();
1234    let bucket = app
1235        .s3_state
1236        .buckets
1237        .items
1238        .iter()
1239        .find(|b| &b.name == bucket_name);
1240
1241    let mut lines = vec![];
1242
1243    let block = rounded_block()
1244        .title(format_title("Properties"))
1245        .border_style(active_border());
1246    let inner = block.inner(area);
1247
1248    // Bucket overview
1249    lines.push(section_header("Bucket overview", inner.width));
1250    if let Some(b) = bucket {
1251        let region = if b.region.is_empty() {
1252            "us-east-1"
1253        } else {
1254            &b.region
1255        };
1256        let formatted_date = if b.creation_date.contains('T') {
1257            let parts: Vec<&str> = b.creation_date.split('T').collect();
1258            if parts.len() == 2 {
1259                format!(
1260                    "{} {} (UTC)",
1261                    parts[0],
1262                    parts[1]
1263                        .trim_end_matches('Z')
1264                        .split('.')
1265                        .next()
1266                        .unwrap_or(parts[1])
1267                )
1268            } else {
1269                b.creation_date.clone()
1270            }
1271        } else {
1272            b.creation_date.clone()
1273        };
1274        lines.push(Line::from(vec![
1275            Span::styled(
1276                "AWS Region: ",
1277                Style::default().add_modifier(Modifier::BOLD),
1278            ),
1279            Span::raw(region),
1280        ]));
1281        lines.push(Line::from(vec![
1282            Span::styled(
1283                "Amazon Resource Name (ARN): ",
1284                Style::default().add_modifier(Modifier::BOLD),
1285            ),
1286            Span::raw(format!("arn:aws:s3:::{}", bucket_name)),
1287        ]));
1288        lines.push(Line::from(vec![
1289            Span::styled(
1290                "Creation date: ",
1291                Style::default().add_modifier(Modifier::BOLD),
1292            ),
1293            Span::raw(formatted_date),
1294        ]));
1295    }
1296    lines.push(Line::from(""));
1297
1298    // Tags
1299    lines.push(section_header("Tags (0)", inner.width));
1300    lines.push(Line::from("No tags associated with this resource."));
1301    lines.push(Line::from(""));
1302
1303    // Default encryption
1304    lines.push(section_header("Default encryption", inner.width));
1305    lines.push(Line::from(vec![
1306        Span::styled(
1307            "Encryption type: ",
1308            Style::default().add_modifier(Modifier::BOLD),
1309        ),
1310        Span::raw("Server-side encryption with Amazon S3 managed keys (SSE-S3)"),
1311    ]));
1312    lines.push(Line::from(vec![
1313        Span::styled(
1314            "Bucket Key: ",
1315            Style::default().add_modifier(Modifier::BOLD),
1316        ),
1317        Span::raw("Disabled"),
1318    ]));
1319    lines.push(Line::from(""));
1320
1321    // Server access logging
1322    lines.push(section_header("Server access logging", inner.width));
1323    lines.push(Line::from("Disabled"));
1324    lines.push(Line::from(""));
1325
1326    // CloudTrail
1327    lines.push(section_header("AWS CloudTrail data events", inner.width));
1328    lines.push(Line::from("Configure in CloudTrail console"));
1329    lines.push(Line::from(""));
1330
1331    // EventBridge
1332    lines.push(section_header("Amazon EventBridge", inner.width));
1333    lines.push(Line::from(vec![
1334        Span::styled(
1335            "Send notifications to Amazon EventBridge: ",
1336            Style::default().add_modifier(Modifier::BOLD),
1337        ),
1338        Span::raw("Off"),
1339    ]));
1340    lines.push(Line::from(""));
1341
1342    // Transfer acceleration
1343    lines.push(section_header("Transfer acceleration", inner.width));
1344    lines.push(Line::from("Disabled"));
1345    lines.push(Line::from(""));
1346
1347    // Object Lock
1348    lines.push(section_header("Object Lock", inner.width));
1349    lines.push(Line::from("Disabled"));
1350    lines.push(Line::from(""));
1351
1352    // Requester pays
1353    lines.push(section_header("Requester pays", inner.width));
1354    lines.push(Line::from("Disabled"));
1355    lines.push(Line::from(""));
1356
1357    // Static website hosting
1358    lines.push(section_header("Static website hosting", inner.width));
1359    lines.push(Line::from("Disabled"));
1360
1361    let paragraph = Paragraph::new(lines)
1362        .block(block)
1363        .wrap(Wrap { trim: false })
1364        .scroll((app.s3_state.properties_scroll, 0));
1365
1366    frame.render_widget(paragraph, area);
1367
1368    // Render scrollbar if needed
1369    let content_height = 40; // Approximate line count
1370    if content_height > area.height.saturating_sub(2) {
1371        render_scrollbar(
1372            frame,
1373            area.inner(Margin {
1374                vertical: 1,
1375                horizontal: 0,
1376            }),
1377            content_height as usize,
1378            app.s3_state.properties_scroll as usize,
1379        );
1380    }
1381}
1382
1383// S3-specific helper functions
1384pub async fn load_s3_buckets(app: &mut App) -> anyhow::Result<()> {
1385    let buckets = app.s3_client.list_buckets().await?;
1386    app.s3_state.buckets.items = buckets
1387        .into_iter()
1388        .map(|(name, region, date)| AppS3Bucket {
1389            name,
1390            region,
1391            creation_date: date,
1392        })
1393        .collect();
1394    Ok(())
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399    use super::*;
1400
1401    #[test]
1402    fn test_calculate_total_bucket_rows_no_expansion() {
1403        let mut state = State::new();
1404        state.buckets.items = vec![
1405            S3Bucket {
1406                name: "bucket1".to_string(),
1407                region: "us-east-1".to_string(),
1408                creation_date: String::new(),
1409            },
1410            S3Bucket {
1411                name: "bucket2".to_string(),
1412                region: "us-west-2".to_string(),
1413                creation_date: String::new(),
1414            },
1415        ];
1416
1417        assert_eq!(state.calculate_total_bucket_rows(), 2);
1418    }
1419
1420    #[test]
1421    fn test_calculate_total_bucket_rows_with_expansion() {
1422        let mut state = State::new();
1423        state.buckets.items = vec![S3Bucket {
1424            name: "bucket1".to_string(),
1425            region: "us-east-1".to_string(),
1426            creation_date: String::new(),
1427        }];
1428
1429        // Expand bucket1
1430        state.expanded_prefixes.insert("bucket1".to_string());
1431        state.bucket_preview.insert(
1432            "bucket1".to_string(),
1433            vec![
1434                S3Object {
1435                    key: "file1.txt".to_string(),
1436                    is_prefix: false,
1437                    size: 100,
1438                    last_modified: String::new(),
1439                    storage_class: String::new(),
1440                },
1441                S3Object {
1442                    key: "folder1/".to_string(),
1443                    is_prefix: true,
1444                    size: 0,
1445                    last_modified: String::new(),
1446                    storage_class: String::new(),
1447                },
1448            ],
1449        );
1450
1451        // 1 bucket + 2 preview items
1452        assert_eq!(state.calculate_total_bucket_rows(), 3);
1453    }
1454
1455    #[test]
1456    fn test_calculate_total_bucket_rows_nested_expansion() {
1457        let mut state = State::new();
1458        state.buckets.items = vec![S3Bucket {
1459            name: "bucket1".to_string(),
1460            region: "us-east-1".to_string(),
1461            creation_date: String::new(),
1462        }];
1463
1464        // Expand bucket1
1465        state.expanded_prefixes.insert("bucket1".to_string());
1466        state.bucket_preview.insert(
1467            "bucket1".to_string(),
1468            vec![S3Object {
1469                key: "folder1/".to_string(),
1470                is_prefix: true,
1471                size: 0,
1472                last_modified: String::new(),
1473                storage_class: String::new(),
1474            }],
1475        );
1476
1477        // Expand folder1
1478        state.expanded_prefixes.insert("folder1/".to_string());
1479        state.prefix_preview.insert(
1480            "folder1/".to_string(),
1481            vec![
1482                S3Object {
1483                    key: "folder1/file1.txt".to_string(),
1484                    is_prefix: false,
1485                    size: 100,
1486                    last_modified: String::new(),
1487                    storage_class: String::new(),
1488                },
1489                S3Object {
1490                    key: "folder1/file2.txt".to_string(),
1491                    is_prefix: false,
1492                    size: 200,
1493                    last_modified: String::new(),
1494                    storage_class: String::new(),
1495                },
1496            ],
1497        );
1498
1499        // 1 bucket + 1 folder + 2 nested files
1500        assert_eq!(state.calculate_total_bucket_rows(), 4);
1501    }
1502
1503    #[test]
1504    fn test_calculate_total_bucket_rows_deeply_nested() {
1505        let mut state = State::new();
1506        state.buckets.items = vec![S3Bucket {
1507            name: "bucket1".to_string(),
1508            region: "us-east-1".to_string(),
1509            creation_date: String::new(),
1510        }];
1511
1512        // Expand bucket1 with folder1
1513        state.expanded_prefixes.insert("bucket1".to_string());
1514        state.bucket_preview.insert(
1515            "bucket1".to_string(),
1516            vec![S3Object {
1517                key: "folder1/".to_string(),
1518                is_prefix: true,
1519                size: 0,
1520                last_modified: String::new(),
1521                storage_class: String::new(),
1522            }],
1523        );
1524
1525        // Expand folder1 with folder2
1526        state.expanded_prefixes.insert("folder1/".to_string());
1527        state.prefix_preview.insert(
1528            "folder1/".to_string(),
1529            vec![S3Object {
1530                key: "folder1/folder2/".to_string(),
1531                is_prefix: true,
1532                size: 0,
1533                last_modified: String::new(),
1534                storage_class: String::new(),
1535            }],
1536        );
1537
1538        // Expand folder2 with files
1539        state
1540            .expanded_prefixes
1541            .insert("folder1/folder2/".to_string());
1542        state.prefix_preview.insert(
1543            "folder1/folder2/".to_string(),
1544            vec![
1545                S3Object {
1546                    key: "folder1/folder2/file1.txt".to_string(),
1547                    is_prefix: false,
1548                    size: 100,
1549                    last_modified: String::new(),
1550                    storage_class: String::new(),
1551                },
1552                S3Object {
1553                    key: "folder1/folder2/file2.txt".to_string(),
1554                    is_prefix: false,
1555                    size: 200,
1556                    last_modified: String::new(),
1557                    storage_class: String::new(),
1558                },
1559                S3Object {
1560                    key: "folder1/folder2/file3.txt".to_string(),
1561                    is_prefix: false,
1562                    size: 300,
1563                    last_modified: String::new(),
1564                    storage_class: String::new(),
1565                },
1566            ],
1567        );
1568
1569        // 1 bucket + 1 folder1 + 1 folder2 + 3 files = 6
1570        assert_eq!(state.calculate_total_bucket_rows(), 6);
1571    }
1572
1573    #[test]
1574    fn test_calculate_total_object_rows_no_expansion() {
1575        let mut state = State::new();
1576        state.objects = vec![
1577            S3Object {
1578                key: "folder1/".to_string(),
1579                is_prefix: true,
1580                size: 0,
1581                last_modified: String::new(),
1582                storage_class: String::new(),
1583            },
1584            S3Object {
1585                key: "folder2/".to_string(),
1586                is_prefix: true,
1587                size: 0,
1588                last_modified: String::new(),
1589                storage_class: String::new(),
1590            },
1591            S3Object {
1592                key: "file.txt".to_string(),
1593                is_prefix: false,
1594                size: 100,
1595                last_modified: String::new(),
1596                storage_class: String::new(),
1597            },
1598        ];
1599
1600        assert_eq!(state.calculate_total_object_rows(), 3);
1601    }
1602
1603    #[test]
1604    fn test_calculate_total_object_rows_with_expansion() {
1605        let mut state = State::new();
1606        state.objects = vec![
1607            S3Object {
1608                key: "folder1/".to_string(),
1609                is_prefix: true,
1610                size: 0,
1611                last_modified: String::new(),
1612                storage_class: String::new(),
1613            },
1614            S3Object {
1615                key: "file.txt".to_string(),
1616                is_prefix: false,
1617                size: 100,
1618                last_modified: String::new(),
1619                storage_class: String::new(),
1620            },
1621        ];
1622
1623        // Expand folder1
1624        state.expanded_prefixes.insert("folder1/".to_string());
1625        state.prefix_preview.insert(
1626            "folder1/".to_string(),
1627            vec![
1628                S3Object {
1629                    key: "folder1/sub1.txt".to_string(),
1630                    is_prefix: false,
1631                    size: 50,
1632                    last_modified: String::new(),
1633                    storage_class: String::new(),
1634                },
1635                S3Object {
1636                    key: "folder1/sub2.txt".to_string(),
1637                    is_prefix: false,
1638                    size: 75,
1639                    last_modified: String::new(),
1640                    storage_class: String::new(),
1641                },
1642            ],
1643        );
1644
1645        // 2 root objects + 2 children in folder1
1646        assert_eq!(state.calculate_total_object_rows(), 4);
1647    }
1648
1649    #[test]
1650    fn test_calculate_total_object_rows_nested_expansion() {
1651        let mut state = State::new();
1652        state.objects = vec![S3Object {
1653            key: "folder1/".to_string(),
1654            is_prefix: true,
1655            size: 0,
1656            last_modified: String::new(),
1657            storage_class: String::new(),
1658        }];
1659
1660        // Expand folder1
1661        state.expanded_prefixes.insert("folder1/".to_string());
1662        state.prefix_preview.insert(
1663            "folder1/".to_string(),
1664            vec![S3Object {
1665                key: "folder1/folder2/".to_string(),
1666                is_prefix: true,
1667                size: 0,
1668                last_modified: String::new(),
1669                storage_class: String::new(),
1670            }],
1671        );
1672
1673        // Expand folder2
1674        state
1675            .expanded_prefixes
1676            .insert("folder1/folder2/".to_string());
1677        state.prefix_preview.insert(
1678            "folder1/folder2/".to_string(),
1679            vec![
1680                S3Object {
1681                    key: "folder1/folder2/file1.txt".to_string(),
1682                    is_prefix: false,
1683                    size: 100,
1684                    last_modified: String::new(),
1685                    storage_class: String::new(),
1686                },
1687                S3Object {
1688                    key: "folder1/folder2/file2.txt".to_string(),
1689                    is_prefix: false,
1690                    size: 200,
1691                    last_modified: String::new(),
1692                    storage_class: String::new(),
1693                },
1694            ],
1695        );
1696
1697        // 1 root + 1 child folder + 2 nested files
1698        assert_eq!(state.calculate_total_object_rows(), 4);
1699    }
1700
1701    #[test]
1702    fn test_scrollbar_needed_when_rows_exceed_visible_area() {
1703        let visible_rows = 10;
1704        let total_rows = 15;
1705
1706        // Scrollbar should be shown when total > visible
1707        assert!(total_rows > visible_rows);
1708    }
1709
1710    #[test]
1711    fn test_scrollbar_not_needed_when_rows_fit() {
1712        let visible_rows = 20;
1713        let total_rows = 10;
1714
1715        // Scrollbar should not be shown when total <= visible
1716        assert!(total_rows <= visible_rows);
1717    }
1718
1719    #[test]
1720    fn test_scroll_offset_adjusts_when_selection_below_viewport() {
1721        let mut state = State::new();
1722        state.bucket_visible_rows.set(10);
1723        state.bucket_scroll_offset = 0;
1724        state.selected_row = 15; // Beyond visible area (0-9)
1725
1726        // Scroll offset should adjust to keep selection visible
1727        let visible_rows = state.bucket_visible_rows.get();
1728        if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1729            state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1730        }
1731
1732        assert_eq!(state.bucket_scroll_offset, 6); // 15 - 10 + 1 = 6
1733        assert!(state.selected_row >= state.bucket_scroll_offset);
1734        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1735    }
1736
1737    #[test]
1738    fn test_scroll_offset_adjusts_when_selection_above_viewport() {
1739        let mut state = State::new();
1740        state.bucket_visible_rows.set(10);
1741        state.bucket_scroll_offset = 10;
1742        state.selected_row = 5; // Above visible area (10-19)
1743
1744        // Scroll offset should adjust to keep selection visible
1745        if state.selected_row < state.bucket_scroll_offset {
1746            state.bucket_scroll_offset = state.selected_row;
1747        }
1748
1749        assert_eq!(state.bucket_scroll_offset, 5);
1750        assert!(state.selected_row >= state.bucket_scroll_offset);
1751    }
1752
1753    #[test]
1754    fn test_selection_stays_visible_during_navigation() {
1755        let mut state = State::new();
1756        state.bucket_visible_rows.set(10);
1757        state.bucket_scroll_offset = 0;
1758        state.selected_row = 0;
1759
1760        // Navigate down 15 times
1761        for _ in 0..15 {
1762            state.selected_row += 1;
1763            let visible_rows = state.bucket_visible_rows.get();
1764            if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1765                state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1766            }
1767        }
1768
1769        // Selection should be at row 15, scroll offset should keep it visible
1770        assert_eq!(state.selected_row, 15);
1771        assert_eq!(state.bucket_scroll_offset, 6);
1772        assert!(state.selected_row >= state.bucket_scroll_offset);
1773        assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1774    }
1775
1776    #[test]
1777    fn test_scroll_offset_adjusts_after_jumping_to_parent() {
1778        let mut state = State::new();
1779        state.bucket_visible_rows.set(10);
1780        state.bucket_scroll_offset = 10; // Viewing rows 10-19
1781        state.selected_row = 15; // Selected row in middle of viewport
1782
1783        // Jump to parent at row 5 (above viewport)
1784        state.selected_row = 5;
1785
1786        // Adjust scroll offset
1787        let visible_rows = state.bucket_visible_rows.get();
1788        if state.selected_row < state.bucket_scroll_offset {
1789            state.bucket_scroll_offset = state.selected_row;
1790        } else if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1791            state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1792        }
1793
1794        // Scroll offset should adjust to show the parent
1795        assert_eq!(state.bucket_scroll_offset, 5);
1796        assert!(state.selected_row >= state.bucket_scroll_offset);
1797        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1798    }
1799
1800    #[test]
1801    fn test_scroll_offset_adjusts_after_collapse() {
1802        let mut state = State::new();
1803        state.buckets.items = vec![S3Bucket {
1804            name: "bucket1".to_string(),
1805            region: "us-east-1".to_string(),
1806            creation_date: String::new(),
1807        }];
1808
1809        // Expand bucket with many items
1810        state.expanded_prefixes.insert("bucket1".to_string());
1811        let mut preview = vec![];
1812        for i in 0..20 {
1813            preview.push(S3Object {
1814                key: format!("file{}.txt", i),
1815                is_prefix: false,
1816                size: 100,
1817                last_modified: String::new(),
1818                storage_class: String::new(),
1819            });
1820        }
1821        state.bucket_preview.insert("bucket1".to_string(), preview);
1822
1823        state.bucket_visible_rows.set(10);
1824        state.bucket_scroll_offset = 10; // Viewing rows 10-19
1825        state.selected_row = 15; // Selected row in middle of expanded items
1826
1827        // Collapse the bucket
1828        state.expanded_prefixes.remove("bucket1");
1829
1830        // Selection should now be on the bucket itself (row 0)
1831        state.selected_row = 0;
1832
1833        // Adjust scroll offset
1834        if state.selected_row < state.bucket_scroll_offset {
1835            state.bucket_scroll_offset = state.selected_row;
1836        }
1837
1838        // Scroll offset should adjust to show the bucket
1839        assert_eq!(state.bucket_scroll_offset, 0);
1840        assert!(state.selected_row >= state.bucket_scroll_offset);
1841    }
1842
1843    #[test]
1844    fn test_object_scroll_offset_adjusts_after_jumping_to_parent() {
1845        let mut state = State::new();
1846        state.objects = vec![S3Object {
1847            key: "folder1/".to_string(),
1848            is_prefix: true,
1849            size: 0,
1850            last_modified: String::new(),
1851            storage_class: String::new(),
1852        }];
1853
1854        // Expand folder with many items
1855        state.expanded_prefixes.insert("folder1/".to_string());
1856        let mut preview = vec![];
1857        for i in 0..20 {
1858            preview.push(S3Object {
1859                key: format!("folder1/file{}.txt", i),
1860                is_prefix: false,
1861                size: 100,
1862                last_modified: String::new(),
1863                storage_class: String::new(),
1864            });
1865        }
1866        state.prefix_preview.insert("folder1/".to_string(), preview);
1867
1868        state.object_visible_rows.set(10);
1869        state.object_scroll_offset = 10; // Viewing rows 10-19
1870        state.selected_object = 15; // Selected row in middle of expanded items
1871
1872        // Jump to parent folder (row 0)
1873        state.selected_object = 0;
1874
1875        // Adjust scroll offset
1876        let visible_rows = state.object_visible_rows.get();
1877        if state.selected_object < state.object_scroll_offset {
1878            state.object_scroll_offset = state.selected_object;
1879        } else if state.selected_object >= state.object_scroll_offset + visible_rows {
1880            state.object_scroll_offset = state.selected_object.saturating_sub(visible_rows - 1);
1881        }
1882
1883        // Scroll offset should adjust to show the parent
1884        assert_eq!(state.object_scroll_offset, 0);
1885        assert!(state.selected_object >= state.object_scroll_offset);
1886        assert!(state.selected_object < state.object_scroll_offset + visible_rows);
1887    }
1888
1889    #[test]
1890    fn test_selection_below_viewport_becomes_visible() {
1891        let mut state = State::new();
1892        state.bucket_visible_rows.set(10);
1893        state.bucket_scroll_offset = 0; // Viewing rows 0-9
1894        state.selected_row = 0;
1895
1896        // Jump to row 20 (way below viewport)
1897        state.selected_row = 20;
1898
1899        // Adjust scroll offset
1900        let visible_rows = state.bucket_visible_rows.get();
1901        if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1902            state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1903        }
1904
1905        // Scroll offset should adjust to show the selection
1906        assert_eq!(state.bucket_scroll_offset, 11); // 20 - 10 + 1
1907        assert!(state.selected_row >= state.bucket_scroll_offset);
1908        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1909    }
1910
1911    #[test]
1912    fn test_scroll_keeps_selection_visible_when_navigating_down() {
1913        let mut state = State::new();
1914        state.buckets.items = vec![];
1915        for i in 0..50 {
1916            state.buckets.items.push(S3Bucket {
1917                name: format!("bucket{}", i),
1918                region: "us-east-1".to_string(),
1919                creation_date: String::new(),
1920            });
1921        }
1922
1923        state.bucket_visible_rows.set(10);
1924        state.bucket_scroll_offset = 0;
1925        state.selected_row = 0;
1926
1927        // Navigate down 25 times
1928        for _ in 0..25 {
1929            let total_rows = state.buckets.items.len();
1930            state.selected_row = (state.selected_row + 1).min(total_rows - 1);
1931
1932            // Adjust scroll offset (same logic as in app.rs)
1933            let visible_rows = state.bucket_visible_rows.get();
1934            if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1935                state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1936            }
1937        }
1938
1939        // Selection should be at row 25
1940        assert_eq!(state.selected_row, 25);
1941        // Scroll offset should be 16 (25 - 10 + 1)
1942        assert_eq!(state.bucket_scroll_offset, 16);
1943        // Selection should be visible
1944        assert!(state.selected_row >= state.bucket_scroll_offset);
1945        assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1946    }
1947
1948    #[test]
1949    fn test_ctrl_d_adjusts_scroll_offset() {
1950        let mut state = State::new();
1951        state.buckets.items = vec![];
1952        for i in 0..50 {
1953            state.buckets.items.push(S3Bucket {
1954                name: format!("bucket{}", i),
1955                region: "us-east-1".to_string(),
1956                creation_date: String::new(),
1957            });
1958        }
1959
1960        state.bucket_visible_rows.set(10);
1961        state.bucket_scroll_offset = 0;
1962        state.selected_row = 5;
1963
1964        // Simulate Ctrl+D (jump down 10)
1965        let total_rows = state.buckets.items.len();
1966        state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
1967
1968        // Adjust scroll offset
1969        let visible_rows = state.bucket_visible_rows.get();
1970        if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1971            state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1972        }
1973
1974        // Selection should be at row 15
1975        assert_eq!(state.selected_row, 15);
1976        // Scroll offset should be 6 (15 - 10 + 1)
1977        assert_eq!(state.bucket_scroll_offset, 6);
1978        // Selection should be visible
1979        assert!(state.selected_row >= state.bucket_scroll_offset);
1980        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1981    }
1982
1983    #[test]
1984    fn test_ctrl_u_adjusts_scroll_offset() {
1985        let mut state = State::new();
1986        state.buckets.items = vec![];
1987        for i in 0..50 {
1988            state.buckets.items.push(S3Bucket {
1989                name: format!("bucket{}", i),
1990                region: "us-east-1".to_string(),
1991                creation_date: String::new(),
1992            });
1993        }
1994
1995        state.bucket_visible_rows.set(10);
1996        state.bucket_scroll_offset = 10;
1997        state.selected_row = 15;
1998
1999        // Simulate Ctrl+U (jump up 10)
2000        state.selected_row = state.selected_row.saturating_sub(10);
2001
2002        // Adjust scroll offset
2003        if state.selected_row < state.bucket_scroll_offset {
2004            state.bucket_scroll_offset = state.selected_row;
2005        }
2006
2007        // Selection should be at row 5
2008        assert_eq!(state.selected_row, 5);
2009        // Scroll offset should be 5
2010        assert_eq!(state.bucket_scroll_offset, 5);
2011        // Selection should be visible
2012        assert!(state.selected_row >= state.bucket_scroll_offset);
2013    }
2014
2015    #[test]
2016    fn test_ctrl_d_clamps_to_max_rows() {
2017        let mut state = State::new();
2018        state.buckets.items = vec![];
2019        for i in 0..20 {
2020            state.buckets.items.push(S3Bucket {
2021                name: format!("bucket{}", i),
2022                region: "us-east-1".to_string(),
2023                creation_date: String::new(),
2024            });
2025        }
2026
2027        state.bucket_visible_rows.set(10);
2028        state.bucket_scroll_offset = 5;
2029        state.selected_row = 15;
2030
2031        // Simulate Ctrl+D (jump down 10) - should clamp to max
2032        let total_rows = state.buckets.items.len();
2033        state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
2034
2035        // Selection should be clamped to 19 (last row)
2036        assert_eq!(state.selected_row, 19);
2037    }
2038
2039    #[test]
2040    fn test_rounded_block_with_custom_border_style() {
2041        use ratatui::prelude::Rect;
2042        let block = rounded_block()
2043            .title(format_title("Properties"))
2044            .border_style(active_border());
2045        let area = Rect::new(0, 0, 70, 12);
2046        let inner = block.inner(area);
2047        assert_eq!(inner.width, 68);
2048        assert_eq!(inner.height, 10);
2049    }
2050
2051    #[test]
2052    fn test_column_width_accounts_for_header_text() {
2053        // Test that column widths are at least as wide as header text
2054        let header_name = "Name";
2055        let header_region = "Region";
2056        let header_date = "Creation date";
2057
2058        // First column has no separator
2059        assert!(header_name.len() >= 4);
2060
2061        // Other columns need space for "⋮ " separator (2 chars)
2062        let region_with_sep = header_region.len() + 2;
2063        let date_with_sep = header_date.len() + 2;
2064
2065        assert!(region_with_sep >= header_region.len());
2066        assert!(date_with_sep >= header_date.len());
2067    }
2068
2069    #[test]
2070    fn test_title_formatting_no_double_dash() {
2071        // Test that title doesn't have double dashes
2072        let title = format!("{} ({})", "Directory buckets", 0);
2073        let formatted = format_title(&title);
2074
2075        // Should be "─ Directory buckets (0) "
2076        assert!(formatted.starts_with("─ "));
2077        assert!(formatted.ends_with(" "));
2078        assert!(!formatted.contains("─ ─")); // No double dash
2079    }
2080
2081    #[test]
2082    fn test_collapse_moves_to_parent() {
2083        // Test that collapsing an expanded node moves selection to parent
2084        let mut state = State::new();
2085        state.buckets.items = vec![S3Bucket {
2086            name: "bucket1".to_string(),
2087            region: "us-east-1".to_string(),
2088            creation_date: String::new(),
2089        }];
2090
2091        // Expand bucket with a folder
2092        state.expanded_prefixes.insert("bucket1".to_string());
2093        state.bucket_preview.insert(
2094            "bucket1".to_string(),
2095            vec![S3Object {
2096                key: "folder1/".to_string(),
2097                is_prefix: true,
2098                size: 0,
2099                last_modified: String::new(),
2100                storage_class: String::new(),
2101            }],
2102        );
2103
2104        // Expand the folder
2105        state.expanded_prefixes.insert("folder1/".to_string());
2106        state.prefix_preview.insert(
2107            "folder1/".to_string(),
2108            vec![S3Object {
2109                key: "folder1/file.txt".to_string(),
2110                is_prefix: false,
2111                size: 100,
2112                last_modified: String::new(),
2113                storage_class: String::new(),
2114            }],
2115        );
2116
2117        // Select the expanded folder (row 1)
2118        state.selected_row = 1;
2119
2120        // Verify folder is expanded
2121        assert!(state.expanded_prefixes.contains("folder1/"));
2122
2123        // After collapse, selection should move to parent (bucket at row 0)
2124        // This is tested in app.rs Left key handling
2125    }
2126
2127    #[test]
2128    fn test_hierarchy_collapse_sequence() {
2129        // Test that pressing left multiple times collapses hierarchy level by level
2130        let mut state = State::new();
2131        state.buckets.items = vec![S3Bucket {
2132            name: "bucket1".to_string(),
2133            region: "us-east-1".to_string(),
2134            creation_date: String::new(),
2135        }];
2136
2137        // Create 3-level hierarchy
2138        state.expanded_prefixes.insert("bucket1".to_string());
2139        state.bucket_preview.insert(
2140            "bucket1".to_string(),
2141            vec![S3Object {
2142                key: "level1/".to_string(),
2143                is_prefix: true,
2144                size: 0,
2145                last_modified: String::new(),
2146                storage_class: String::new(),
2147            }],
2148        );
2149
2150        state.expanded_prefixes.insert("level1/".to_string());
2151        state.prefix_preview.insert(
2152            "level1/".to_string(),
2153            vec![S3Object {
2154                key: "level1/level2/".to_string(),
2155                is_prefix: true,
2156                size: 0,
2157                last_modified: String::new(),
2158                storage_class: String::new(),
2159            }],
2160        );
2161
2162        state.expanded_prefixes.insert("level1/level2/".to_string());
2163        state.prefix_preview.insert(
2164            "level1/level2/".to_string(),
2165            vec![S3Object {
2166                key: "level1/level2/file.txt".to_string(),
2167                is_prefix: false,
2168                size: 100,
2169                last_modified: String::new(),
2170                storage_class: String::new(),
2171            }],
2172        );
2173
2174        // All 3 levels should be expanded
2175        assert_eq!(state.expanded_prefixes.len(), 3);
2176
2177        // After 3 left presses, all should be collapsed
2178        // This is tested in app.rs Left key handling
2179    }
2180
2181    #[test]
2182    fn test_objects_collapse_jumps_to_parent() {
2183        // Test that collapsing an expanded prefix in objects view jumps to parent
2184        let mut state = State::new();
2185        state.current_bucket = Some("test-bucket".to_string());
2186
2187        // Create hierarchy: folder1/ -> folder2/ -> file.txt
2188        state.objects = vec![S3Object {
2189            key: "folder1/".to_string(),
2190            is_prefix: true,
2191            size: 0,
2192            last_modified: String::new(),
2193            storage_class: String::new(),
2194        }];
2195
2196        state.expanded_prefixes.insert("folder1/".to_string());
2197        state.prefix_preview.insert(
2198            "folder1/".to_string(),
2199            vec![S3Object {
2200                key: "folder1/folder2/".to_string(),
2201                is_prefix: true,
2202                size: 0,
2203                last_modified: String::new(),
2204                storage_class: String::new(),
2205            }],
2206        );
2207
2208        state
2209            .expanded_prefixes
2210            .insert("folder1/folder2/".to_string());
2211        state.prefix_preview.insert(
2212            "folder1/folder2/".to_string(),
2213            vec![S3Object {
2214                key: "folder1/folder2/file.txt".to_string(),
2215                is_prefix: false,
2216                size: 100,
2217                last_modified: String::new(),
2218                storage_class: String::new(),
2219            }],
2220        );
2221
2222        // Select folder2 (visual index 1: folder1, then folder2)
2223        state.selected_object = 1;
2224
2225        // Verify folder2 is expanded
2226        assert!(state.expanded_prefixes.contains("folder1/folder2/"));
2227
2228        // After collapse, folder2 should be collapsed and selection should jump to parent (folder1)
2229        // This behavior is tested in app.rs prev_pane function
2230        // Expected: expanded_prefixes.remove("folder1/folder2/") and selected_object = 0
2231    }
2232}