Skip to main content

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    // Calculate max name width
969    let max_name_width = filtered_objects
970        .iter()
971        .map(|(_, obj)| {
972            let name = obj.key.trim_start_matches(
973                &app.s3_state
974                    .prefix_stack
975                    .last()
976                    .cloned()
977                    .unwrap_or_default(),
978            );
979            name.len() + 4 // +4 for icon and expand indicator
980        })
981        .max()
982        .unwrap_or(30)
983        .max(30) as u16;
984
985    let rows: Vec<Row> = filtered_objects
986        .iter()
987        .flat_map(|(idx, obj)| {
988            let icon = if obj.is_prefix { "📁" } else { "📄" };
989
990            // Add expand indicator for prefixes
991            let expand_indicator = if obj.is_prefix {
992                if app.s3_state.expanded_prefixes.contains(&obj.key) {
993                    format!("{} ", CURSOR_EXPANDED)
994                } else {
995                    format!("{} ", CURSOR_COLLAPSED)
996                }
997            } else {
998                String::new()
999            };
1000
1001            let name = obj.key.trim_start_matches(
1002                &app.s3_state
1003                    .prefix_stack
1004                    .last()
1005                    .cloned()
1006                    .unwrap_or_default(),
1007            );
1008            let display_name = format!("{}{} {}", expand_indicator, icon, name);
1009            let obj_type = if obj.is_prefix { "Folder" } else { "File" };
1010            let size = if obj.is_prefix {
1011                String::new()
1012            } else {
1013                format_bytes(obj.size)
1014            };
1015
1016            // Format datetime with (UTC)
1017            let datetime = format_iso_timestamp(&obj.last_modified);
1018
1019            // Format storage class
1020            let storage = if obj.storage_class.is_empty() {
1021                String::new()
1022            } else {
1023                obj.storage_class
1024                    .chars()
1025                    .next()
1026                    .unwrap()
1027                    .to_uppercase()
1028                    .to_string()
1029                    + &obj.storage_class[1..].to_lowercase()
1030            };
1031
1032            // Calculate row index including nested items
1033            let mut row_idx = *idx;
1034            for i in 0..*idx {
1035                if let Some(prev_obj) = app.s3_state.objects.get(i) {
1036                    if prev_obj.is_prefix && app.s3_state.expanded_prefixes.contains(&prev_obj.key)
1037                    {
1038                        if let Some(preview) = app.s3_state.prefix_preview.get(&prev_obj.key) {
1039                            row_idx += preview.len();
1040                        }
1041                    }
1042                }
1043            }
1044
1045            let style = if row_idx == app.s3_state.selected_object {
1046                Style::default().bg(Color::DarkGray)
1047            } else {
1048                Style::default()
1049            };
1050
1051            let mut result = vec![Row::new(vec![
1052                Cell::from(display_name),
1053                Cell::from(format!("⋮ {}", obj_type)),
1054                Cell::from(format!("⋮ {}", datetime)),
1055                Cell::from(format!("⋮ {}", size)),
1056                Cell::from(format!("⋮ {}", storage)),
1057            ])
1058            .style(style)];
1059
1060            let mut child_row_idx = row_idx + 1;
1061
1062            if obj.is_prefix && app.s3_state.expanded_prefixes.contains(&obj.key) {
1063                if let Some(preview) = app.s3_state.prefix_preview.get(&obj.key) {
1064                    // Recursive function to render nested objects
1065                    fn render_nested_objects<'a>(
1066                        objects: &'a [S3Object],
1067                        app: &'a App,
1068                        child_row_idx: &mut usize,
1069                        result: &mut Vec<Row<'a>>,
1070                        parent_key: &str,
1071                        is_last: &[bool],
1072                    ) {
1073                        for (child_idx, preview_obj) in objects.iter().enumerate() {
1074                            let is_last_child = child_idx == objects.len() - 1;
1075                            let obj_is_expanded =
1076                                app.s3_state.expanded_prefixes.contains(&preview_obj.key);
1077
1078                            // Build prefix with tree characters
1079                            let mut prefix = String::new();
1080                            for &last in is_last.iter() {
1081                                prefix.push_str(if last { "  " } else { "│ " });
1082                            }
1083
1084                            let tree_char = if is_last_child { "╰─" } else { "├─" };
1085                            let child_expand = if preview_obj.is_prefix {
1086                                if obj_is_expanded {
1087                                    CURSOR_EXPANDED
1088                                } else {
1089                                    CURSOR_COLLAPSED
1090                                }
1091                            } else {
1092                                ""
1093                            };
1094                            let child_icon = if preview_obj.is_prefix {
1095                                "📁"
1096                            } else {
1097                                "📄"
1098                            };
1099                            let child_name = preview_obj
1100                                .key
1101                                .strip_prefix(parent_key)
1102                                .unwrap_or(&preview_obj.key);
1103
1104                            let child_type = if preview_obj.is_prefix {
1105                                "Folder"
1106                            } else {
1107                                "File"
1108                            };
1109                            let child_size = if preview_obj.is_prefix {
1110                                String::new()
1111                            } else {
1112                                format_bytes(preview_obj.size)
1113                            };
1114                            let child_datetime = format_iso_timestamp(&preview_obj.last_modified);
1115                            let child_storage = if preview_obj.storage_class.is_empty() {
1116                                String::new()
1117                            } else {
1118                                preview_obj
1119                                    .storage_class
1120                                    .chars()
1121                                    .next()
1122                                    .unwrap()
1123                                    .to_uppercase()
1124                                    .to_string()
1125                                    + &preview_obj.storage_class[1..].to_lowercase()
1126                            };
1127
1128                            let child_style = if *child_row_idx == app.s3_state.selected_object {
1129                                Style::default().bg(Color::DarkGray)
1130                            } else {
1131                                Style::default()
1132                            };
1133
1134                            result.push(
1135                                Row::new(vec![
1136                                    Cell::from(format!(
1137                                        "{}{}{} {} {}",
1138                                        prefix, tree_char, child_expand, child_icon, child_name
1139                                    )),
1140                                    Cell::from(format!("⋮ {}", child_type)),
1141                                    Cell::from(format!("⋮ {}", child_datetime)),
1142                                    Cell::from(format!("⋮ {}", child_size)),
1143                                    Cell::from(format!("⋮ {}", child_storage)),
1144                                ])
1145                                .style(child_style),
1146                            );
1147                            *child_row_idx += 1;
1148
1149                            // Recursively render nested children
1150                            if preview_obj.is_prefix && obj_is_expanded {
1151                                if let Some(nested_preview) =
1152                                    app.s3_state.prefix_preview.get(&preview_obj.key)
1153                                {
1154                                    let mut new_is_last = is_last.to_vec();
1155                                    new_is_last.push(is_last_child);
1156                                    render_nested_objects(
1157                                        nested_preview,
1158                                        app,
1159                                        child_row_idx,
1160                                        result,
1161                                        &preview_obj.key,
1162                                        &new_is_last,
1163                                    );
1164                                }
1165                            }
1166                        }
1167                    }
1168
1169                    render_nested_objects(
1170                        preview,
1171                        app,
1172                        &mut child_row_idx,
1173                        &mut result,
1174                        &obj.key,
1175                        &[],
1176                    );
1177                }
1178            }
1179
1180            result
1181        })
1182        .skip(app.s3_state.object_scroll_offset)
1183        .take(app.s3_state.object_visible_rows.get())
1184        .collect();
1185
1186    crate::ui::table::render_tree_table(
1187        frame,
1188        area,
1189        title,
1190        vec!["Name", "Type", "Last modified", "Size", "Storage class"],
1191        rows,
1192        vec![
1193            Constraint::Length(max_name_width),
1194            Constraint::Length(10),
1195            Constraint::Length(UTC_TIMESTAMP_WIDTH),
1196            Constraint::Length(12),
1197            Constraint::Length(15),
1198        ],
1199        true,
1200    );
1201
1202    // Render scrollbar if content exceeds visible area
1203    let total_rows = app.s3_state.calculate_total_object_rows();
1204    let visible_rows = area.height.saturating_sub(3) as usize;
1205    if total_rows > visible_rows {
1206        render_scrollbar(
1207            frame,
1208            area.inner(Margin {
1209                vertical: 1,
1210                horizontal: 0,
1211            }),
1212            total_rows,
1213            app.s3_state.selected_object,
1214        );
1215    }
1216}
1217
1218fn render_bucket_properties(frame: &mut Frame, app: &App, area: Rect) {
1219    let bucket_name = app.s3_state.current_bucket.as_ref().unwrap();
1220    let bucket = app
1221        .s3_state
1222        .buckets
1223        .items
1224        .iter()
1225        .find(|b| &b.name == bucket_name);
1226
1227    let mut lines = vec![];
1228
1229    let block = rounded_block()
1230        .title(format_title("Properties"))
1231        .border_style(active_border());
1232    let inner = block.inner(area);
1233
1234    // Bucket overview
1235    lines.push(section_header("Bucket overview", inner.width));
1236    if let Some(b) = bucket {
1237        let region = if b.region.is_empty() {
1238            "us-east-1"
1239        } else {
1240            &b.region
1241        };
1242        let formatted_date = if b.creation_date.contains('T') {
1243            let parts: Vec<&str> = b.creation_date.split('T').collect();
1244            if parts.len() == 2 {
1245                format!(
1246                    "{} {} (UTC)",
1247                    parts[0],
1248                    parts[1]
1249                        .trim_end_matches('Z')
1250                        .split('.')
1251                        .next()
1252                        .unwrap_or(parts[1])
1253                )
1254            } else {
1255                b.creation_date.clone()
1256            }
1257        } else {
1258            b.creation_date.clone()
1259        };
1260        lines.push(Line::from(vec![
1261            Span::styled(
1262                "AWS Region: ",
1263                Style::default().add_modifier(Modifier::BOLD),
1264            ),
1265            Span::raw(region),
1266        ]));
1267        lines.push(Line::from(vec![
1268            Span::styled(
1269                "Amazon Resource Name (ARN): ",
1270                Style::default().add_modifier(Modifier::BOLD),
1271            ),
1272            Span::raw(format!("arn:aws:s3:::{}", bucket_name)),
1273        ]));
1274        lines.push(Line::from(vec![
1275            Span::styled(
1276                "Creation date: ",
1277                Style::default().add_modifier(Modifier::BOLD),
1278            ),
1279            Span::raw(formatted_date),
1280        ]));
1281    }
1282    lines.push(Line::from(""));
1283
1284    // Tags
1285    lines.push(section_header("Tags (0)", inner.width));
1286    lines.push(Line::from("No tags associated with this resource."));
1287    lines.push(Line::from(""));
1288
1289    // Default encryption
1290    lines.push(section_header("Default encryption", inner.width));
1291    lines.push(Line::from(vec![
1292        Span::styled(
1293            "Encryption type: ",
1294            Style::default().add_modifier(Modifier::BOLD),
1295        ),
1296        Span::raw("Server-side encryption with Amazon S3 managed keys (SSE-S3)"),
1297    ]));
1298    lines.push(Line::from(vec![
1299        Span::styled(
1300            "Bucket Key: ",
1301            Style::default().add_modifier(Modifier::BOLD),
1302        ),
1303        Span::raw("Disabled"),
1304    ]));
1305    lines.push(Line::from(""));
1306
1307    // Server access logging
1308    lines.push(section_header("Server access logging", inner.width));
1309    lines.push(Line::from("Disabled"));
1310    lines.push(Line::from(""));
1311
1312    // CloudTrail
1313    lines.push(section_header("AWS CloudTrail data events", inner.width));
1314    lines.push(Line::from("Configure in CloudTrail console"));
1315    lines.push(Line::from(""));
1316
1317    // EventBridge
1318    lines.push(section_header("Amazon EventBridge", inner.width));
1319    lines.push(Line::from(vec![
1320        Span::styled(
1321            "Send notifications to Amazon EventBridge: ",
1322            Style::default().add_modifier(Modifier::BOLD),
1323        ),
1324        Span::raw("Off"),
1325    ]));
1326    lines.push(Line::from(""));
1327
1328    // Transfer acceleration
1329    lines.push(section_header("Transfer acceleration", inner.width));
1330    lines.push(Line::from("Disabled"));
1331    lines.push(Line::from(""));
1332
1333    // Object Lock
1334    lines.push(section_header("Object Lock", inner.width));
1335    lines.push(Line::from("Disabled"));
1336    lines.push(Line::from(""));
1337
1338    // Requester pays
1339    lines.push(section_header("Requester pays", inner.width));
1340    lines.push(Line::from("Disabled"));
1341    lines.push(Line::from(""));
1342
1343    // Static website hosting
1344    lines.push(section_header("Static website hosting", inner.width));
1345    lines.push(Line::from("Disabled"));
1346
1347    let paragraph = Paragraph::new(lines)
1348        .block(block)
1349        .wrap(Wrap { trim: false })
1350        .scroll((app.s3_state.properties_scroll, 0));
1351
1352    frame.render_widget(paragraph, area);
1353
1354    // Render scrollbar if needed
1355    let content_height = 40; // Approximate line count
1356    if content_height > area.height.saturating_sub(2) {
1357        render_scrollbar(
1358            frame,
1359            area.inner(Margin {
1360                vertical: 1,
1361                horizontal: 0,
1362            }),
1363            content_height as usize,
1364            app.s3_state.properties_scroll as usize,
1365        );
1366    }
1367}
1368
1369// S3-specific helper functions
1370pub async fn load_s3_buckets(app: &mut App) -> anyhow::Result<()> {
1371    let buckets = app.s3_client.list_buckets().await?;
1372    app.s3_state.buckets.items = buckets
1373        .into_iter()
1374        .map(|(name, region, date)| AppS3Bucket {
1375            name,
1376            region,
1377            creation_date: date,
1378        })
1379        .collect();
1380    Ok(())
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385    use super::*;
1386
1387    #[test]
1388    fn test_calculate_total_bucket_rows_no_expansion() {
1389        let mut state = State::new();
1390        state.buckets.items = vec![
1391            S3Bucket {
1392                name: "bucket1".to_string(),
1393                region: "us-east-1".to_string(),
1394                creation_date: String::new(),
1395            },
1396            S3Bucket {
1397                name: "bucket2".to_string(),
1398                region: "us-west-2".to_string(),
1399                creation_date: String::new(),
1400            },
1401        ];
1402
1403        assert_eq!(state.calculate_total_bucket_rows(), 2);
1404    }
1405
1406    #[test]
1407    fn test_calculate_total_bucket_rows_with_expansion() {
1408        let mut state = State::new();
1409        state.buckets.items = vec![S3Bucket {
1410            name: "bucket1".to_string(),
1411            region: "us-east-1".to_string(),
1412            creation_date: String::new(),
1413        }];
1414
1415        // Expand bucket1
1416        state.expanded_prefixes.insert("bucket1".to_string());
1417        state.bucket_preview.insert(
1418            "bucket1".to_string(),
1419            vec![
1420                S3Object {
1421                    key: "file1.txt".to_string(),
1422                    is_prefix: false,
1423                    size: 100,
1424                    last_modified: String::new(),
1425                    storage_class: String::new(),
1426                },
1427                S3Object {
1428                    key: "folder1/".to_string(),
1429                    is_prefix: true,
1430                    size: 0,
1431                    last_modified: String::new(),
1432                    storage_class: String::new(),
1433                },
1434            ],
1435        );
1436
1437        // 1 bucket + 2 preview items
1438        assert_eq!(state.calculate_total_bucket_rows(), 3);
1439    }
1440
1441    #[test]
1442    fn test_calculate_total_bucket_rows_nested_expansion() {
1443        let mut state = State::new();
1444        state.buckets.items = vec![S3Bucket {
1445            name: "bucket1".to_string(),
1446            region: "us-east-1".to_string(),
1447            creation_date: String::new(),
1448        }];
1449
1450        // Expand bucket1
1451        state.expanded_prefixes.insert("bucket1".to_string());
1452        state.bucket_preview.insert(
1453            "bucket1".to_string(),
1454            vec![S3Object {
1455                key: "folder1/".to_string(),
1456                is_prefix: true,
1457                size: 0,
1458                last_modified: String::new(),
1459                storage_class: String::new(),
1460            }],
1461        );
1462
1463        // Expand folder1
1464        state.expanded_prefixes.insert("folder1/".to_string());
1465        state.prefix_preview.insert(
1466            "folder1/".to_string(),
1467            vec![
1468                S3Object {
1469                    key: "folder1/file1.txt".to_string(),
1470                    is_prefix: false,
1471                    size: 100,
1472                    last_modified: String::new(),
1473                    storage_class: String::new(),
1474                },
1475                S3Object {
1476                    key: "folder1/file2.txt".to_string(),
1477                    is_prefix: false,
1478                    size: 200,
1479                    last_modified: String::new(),
1480                    storage_class: String::new(),
1481                },
1482            ],
1483        );
1484
1485        // 1 bucket + 1 folder + 2 nested files
1486        assert_eq!(state.calculate_total_bucket_rows(), 4);
1487    }
1488
1489    #[test]
1490    fn test_calculate_total_bucket_rows_deeply_nested() {
1491        let mut state = State::new();
1492        state.buckets.items = vec![S3Bucket {
1493            name: "bucket1".to_string(),
1494            region: "us-east-1".to_string(),
1495            creation_date: String::new(),
1496        }];
1497
1498        // Expand bucket1 with folder1
1499        state.expanded_prefixes.insert("bucket1".to_string());
1500        state.bucket_preview.insert(
1501            "bucket1".to_string(),
1502            vec![S3Object {
1503                key: "folder1/".to_string(),
1504                is_prefix: true,
1505                size: 0,
1506                last_modified: String::new(),
1507                storage_class: String::new(),
1508            }],
1509        );
1510
1511        // Expand folder1 with folder2
1512        state.expanded_prefixes.insert("folder1/".to_string());
1513        state.prefix_preview.insert(
1514            "folder1/".to_string(),
1515            vec![S3Object {
1516                key: "folder1/folder2/".to_string(),
1517                is_prefix: true,
1518                size: 0,
1519                last_modified: String::new(),
1520                storage_class: String::new(),
1521            }],
1522        );
1523
1524        // Expand folder2 with files
1525        state
1526            .expanded_prefixes
1527            .insert("folder1/folder2/".to_string());
1528        state.prefix_preview.insert(
1529            "folder1/folder2/".to_string(),
1530            vec![
1531                S3Object {
1532                    key: "folder1/folder2/file1.txt".to_string(),
1533                    is_prefix: false,
1534                    size: 100,
1535                    last_modified: String::new(),
1536                    storage_class: String::new(),
1537                },
1538                S3Object {
1539                    key: "folder1/folder2/file2.txt".to_string(),
1540                    is_prefix: false,
1541                    size: 200,
1542                    last_modified: String::new(),
1543                    storage_class: String::new(),
1544                },
1545                S3Object {
1546                    key: "folder1/folder2/file3.txt".to_string(),
1547                    is_prefix: false,
1548                    size: 300,
1549                    last_modified: String::new(),
1550                    storage_class: String::new(),
1551                },
1552            ],
1553        );
1554
1555        // 1 bucket + 1 folder1 + 1 folder2 + 3 files = 6
1556        assert_eq!(state.calculate_total_bucket_rows(), 6);
1557    }
1558
1559    #[test]
1560    fn test_calculate_total_object_rows_no_expansion() {
1561        let mut state = State::new();
1562        state.objects = vec![
1563            S3Object {
1564                key: "folder1/".to_string(),
1565                is_prefix: true,
1566                size: 0,
1567                last_modified: String::new(),
1568                storage_class: String::new(),
1569            },
1570            S3Object {
1571                key: "folder2/".to_string(),
1572                is_prefix: true,
1573                size: 0,
1574                last_modified: String::new(),
1575                storage_class: String::new(),
1576            },
1577            S3Object {
1578                key: "file.txt".to_string(),
1579                is_prefix: false,
1580                size: 100,
1581                last_modified: String::new(),
1582                storage_class: String::new(),
1583            },
1584        ];
1585
1586        assert_eq!(state.calculate_total_object_rows(), 3);
1587    }
1588
1589    #[test]
1590    fn test_calculate_total_object_rows_with_expansion() {
1591        let mut state = State::new();
1592        state.objects = vec![
1593            S3Object {
1594                key: "folder1/".to_string(),
1595                is_prefix: true,
1596                size: 0,
1597                last_modified: String::new(),
1598                storage_class: String::new(),
1599            },
1600            S3Object {
1601                key: "file.txt".to_string(),
1602                is_prefix: false,
1603                size: 100,
1604                last_modified: String::new(),
1605                storage_class: String::new(),
1606            },
1607        ];
1608
1609        // Expand folder1
1610        state.expanded_prefixes.insert("folder1/".to_string());
1611        state.prefix_preview.insert(
1612            "folder1/".to_string(),
1613            vec![
1614                S3Object {
1615                    key: "folder1/sub1.txt".to_string(),
1616                    is_prefix: false,
1617                    size: 50,
1618                    last_modified: String::new(),
1619                    storage_class: String::new(),
1620                },
1621                S3Object {
1622                    key: "folder1/sub2.txt".to_string(),
1623                    is_prefix: false,
1624                    size: 75,
1625                    last_modified: String::new(),
1626                    storage_class: String::new(),
1627                },
1628            ],
1629        );
1630
1631        // 2 root objects + 2 children in folder1
1632        assert_eq!(state.calculate_total_object_rows(), 4);
1633    }
1634
1635    #[test]
1636    fn test_calculate_total_object_rows_nested_expansion() {
1637        let mut state = State::new();
1638        state.objects = vec![S3Object {
1639            key: "folder1/".to_string(),
1640            is_prefix: true,
1641            size: 0,
1642            last_modified: String::new(),
1643            storage_class: String::new(),
1644        }];
1645
1646        // Expand folder1
1647        state.expanded_prefixes.insert("folder1/".to_string());
1648        state.prefix_preview.insert(
1649            "folder1/".to_string(),
1650            vec![S3Object {
1651                key: "folder1/folder2/".to_string(),
1652                is_prefix: true,
1653                size: 0,
1654                last_modified: String::new(),
1655                storage_class: String::new(),
1656            }],
1657        );
1658
1659        // Expand folder2
1660        state
1661            .expanded_prefixes
1662            .insert("folder1/folder2/".to_string());
1663        state.prefix_preview.insert(
1664            "folder1/folder2/".to_string(),
1665            vec![
1666                S3Object {
1667                    key: "folder1/folder2/file1.txt".to_string(),
1668                    is_prefix: false,
1669                    size: 100,
1670                    last_modified: String::new(),
1671                    storage_class: String::new(),
1672                },
1673                S3Object {
1674                    key: "folder1/folder2/file2.txt".to_string(),
1675                    is_prefix: false,
1676                    size: 200,
1677                    last_modified: String::new(),
1678                    storage_class: String::new(),
1679                },
1680            ],
1681        );
1682
1683        // 1 root + 1 child folder + 2 nested files
1684        assert_eq!(state.calculate_total_object_rows(), 4);
1685    }
1686
1687    #[test]
1688    fn test_scrollbar_needed_when_rows_exceed_visible_area() {
1689        let visible_rows = 10;
1690        let total_rows = 15;
1691
1692        // Scrollbar should be shown when total > visible
1693        assert!(total_rows > visible_rows);
1694    }
1695
1696    #[test]
1697    fn test_scrollbar_not_needed_when_rows_fit() {
1698        let visible_rows = 20;
1699        let total_rows = 10;
1700
1701        // Scrollbar should not be shown when total <= visible
1702        assert!(total_rows <= visible_rows);
1703    }
1704
1705    #[test]
1706    fn test_scroll_offset_adjusts_when_selection_below_viewport() {
1707        let mut state = State::new();
1708        state.bucket_visible_rows.set(10);
1709        state.bucket_scroll_offset = 0;
1710        state.selected_row = 15; // Beyond visible area (0-9)
1711
1712        // Scroll offset should adjust to keep selection visible
1713        let visible_rows = state.bucket_visible_rows.get();
1714        if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1715            state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1716        }
1717
1718        assert_eq!(state.bucket_scroll_offset, 6); // 15 - 10 + 1 = 6
1719        assert!(state.selected_row >= state.bucket_scroll_offset);
1720        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1721    }
1722
1723    #[test]
1724    fn test_scroll_offset_adjusts_when_selection_above_viewport() {
1725        let mut state = State::new();
1726        state.bucket_visible_rows.set(10);
1727        state.bucket_scroll_offset = 10;
1728        state.selected_row = 5; // Above visible area (10-19)
1729
1730        // Scroll offset should adjust to keep selection visible
1731        if state.selected_row < state.bucket_scroll_offset {
1732            state.bucket_scroll_offset = state.selected_row;
1733        }
1734
1735        assert_eq!(state.bucket_scroll_offset, 5);
1736        assert!(state.selected_row >= state.bucket_scroll_offset);
1737    }
1738
1739    #[test]
1740    fn test_selection_stays_visible_during_navigation() {
1741        let mut state = State::new();
1742        state.bucket_visible_rows.set(10);
1743        state.bucket_scroll_offset = 0;
1744        state.selected_row = 0;
1745
1746        // Navigate down 15 times
1747        for _ in 0..15 {
1748            state.selected_row += 1;
1749            let visible_rows = state.bucket_visible_rows.get();
1750            if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1751                state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1752            }
1753        }
1754
1755        // Selection should be at row 15, scroll offset should keep it visible
1756        assert_eq!(state.selected_row, 15);
1757        assert_eq!(state.bucket_scroll_offset, 6);
1758        assert!(state.selected_row >= state.bucket_scroll_offset);
1759        assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1760    }
1761
1762    #[test]
1763    fn test_scroll_offset_adjusts_after_jumping_to_parent() {
1764        let mut state = State::new();
1765        state.bucket_visible_rows.set(10);
1766        state.bucket_scroll_offset = 10; // Viewing rows 10-19
1767        state.selected_row = 15; // Selected row in middle of viewport
1768
1769        // Jump to parent at row 5 (above viewport)
1770        state.selected_row = 5;
1771
1772        // Adjust scroll offset
1773        let visible_rows = state.bucket_visible_rows.get();
1774        if state.selected_row < state.bucket_scroll_offset {
1775            state.bucket_scroll_offset = state.selected_row;
1776        } else if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1777            state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1778        }
1779
1780        // Scroll offset should adjust to show the parent
1781        assert_eq!(state.bucket_scroll_offset, 5);
1782        assert!(state.selected_row >= state.bucket_scroll_offset);
1783        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1784    }
1785
1786    #[test]
1787    fn test_scroll_offset_adjusts_after_collapse() {
1788        let mut state = State::new();
1789        state.buckets.items = vec![S3Bucket {
1790            name: "bucket1".to_string(),
1791            region: "us-east-1".to_string(),
1792            creation_date: String::new(),
1793        }];
1794
1795        // Expand bucket with many items
1796        state.expanded_prefixes.insert("bucket1".to_string());
1797        let mut preview = vec![];
1798        for i in 0..20 {
1799            preview.push(S3Object {
1800                key: format!("file{}.txt", i),
1801                is_prefix: false,
1802                size: 100,
1803                last_modified: String::new(),
1804                storage_class: String::new(),
1805            });
1806        }
1807        state.bucket_preview.insert("bucket1".to_string(), preview);
1808
1809        state.bucket_visible_rows.set(10);
1810        state.bucket_scroll_offset = 10; // Viewing rows 10-19
1811        state.selected_row = 15; // Selected row in middle of expanded items
1812
1813        // Collapse the bucket
1814        state.expanded_prefixes.remove("bucket1");
1815
1816        // Selection should now be on the bucket itself (row 0)
1817        state.selected_row = 0;
1818
1819        // Adjust scroll offset
1820        if state.selected_row < state.bucket_scroll_offset {
1821            state.bucket_scroll_offset = state.selected_row;
1822        }
1823
1824        // Scroll offset should adjust to show the bucket
1825        assert_eq!(state.bucket_scroll_offset, 0);
1826        assert!(state.selected_row >= state.bucket_scroll_offset);
1827    }
1828
1829    #[test]
1830    fn test_object_scroll_offset_adjusts_after_jumping_to_parent() {
1831        let mut state = State::new();
1832        state.objects = vec![S3Object {
1833            key: "folder1/".to_string(),
1834            is_prefix: true,
1835            size: 0,
1836            last_modified: String::new(),
1837            storage_class: String::new(),
1838        }];
1839
1840        // Expand folder with many items
1841        state.expanded_prefixes.insert("folder1/".to_string());
1842        let mut preview = vec![];
1843        for i in 0..20 {
1844            preview.push(S3Object {
1845                key: format!("folder1/file{}.txt", i),
1846                is_prefix: false,
1847                size: 100,
1848                last_modified: String::new(),
1849                storage_class: String::new(),
1850            });
1851        }
1852        state.prefix_preview.insert("folder1/".to_string(), preview);
1853
1854        state.object_visible_rows.set(10);
1855        state.object_scroll_offset = 10; // Viewing rows 10-19
1856        state.selected_object = 15; // Selected row in middle of expanded items
1857
1858        // Jump to parent folder (row 0)
1859        state.selected_object = 0;
1860
1861        // Adjust scroll offset
1862        let visible_rows = state.object_visible_rows.get();
1863        if state.selected_object < state.object_scroll_offset {
1864            state.object_scroll_offset = state.selected_object;
1865        } else if state.selected_object >= state.object_scroll_offset + visible_rows {
1866            state.object_scroll_offset = state.selected_object.saturating_sub(visible_rows - 1);
1867        }
1868
1869        // Scroll offset should adjust to show the parent
1870        assert_eq!(state.object_scroll_offset, 0);
1871        assert!(state.selected_object >= state.object_scroll_offset);
1872        assert!(state.selected_object < state.object_scroll_offset + visible_rows);
1873    }
1874
1875    #[test]
1876    fn test_selection_below_viewport_becomes_visible() {
1877        let mut state = State::new();
1878        state.bucket_visible_rows.set(10);
1879        state.bucket_scroll_offset = 0; // Viewing rows 0-9
1880        state.selected_row = 0;
1881
1882        // Jump to row 20 (way below viewport)
1883        state.selected_row = 20;
1884
1885        // Adjust scroll offset
1886        let visible_rows = state.bucket_visible_rows.get();
1887        if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1888            state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1889        }
1890
1891        // Scroll offset should adjust to show the selection
1892        assert_eq!(state.bucket_scroll_offset, 11); // 20 - 10 + 1
1893        assert!(state.selected_row >= state.bucket_scroll_offset);
1894        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1895    }
1896
1897    #[test]
1898    fn test_scroll_keeps_selection_visible_when_navigating_down() {
1899        let mut state = State::new();
1900        state.buckets.items = vec![];
1901        for i in 0..50 {
1902            state.buckets.items.push(S3Bucket {
1903                name: format!("bucket{}", i),
1904                region: "us-east-1".to_string(),
1905                creation_date: String::new(),
1906            });
1907        }
1908
1909        state.bucket_visible_rows.set(10);
1910        state.bucket_scroll_offset = 0;
1911        state.selected_row = 0;
1912
1913        // Navigate down 25 times
1914        for _ in 0..25 {
1915            let total_rows = state.buckets.items.len();
1916            state.selected_row = (state.selected_row + 1).min(total_rows - 1);
1917
1918            // Adjust scroll offset (same logic as in app.rs)
1919            let visible_rows = state.bucket_visible_rows.get();
1920            if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1921                state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1922            }
1923        }
1924
1925        // Selection should be at row 25
1926        assert_eq!(state.selected_row, 25);
1927        // Scroll offset should be 16 (25 - 10 + 1)
1928        assert_eq!(state.bucket_scroll_offset, 16);
1929        // Selection should be visible
1930        assert!(state.selected_row >= state.bucket_scroll_offset);
1931        assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1932    }
1933
1934    #[test]
1935    fn test_ctrl_d_adjusts_scroll_offset() {
1936        let mut state = State::new();
1937        state.buckets.items = vec![];
1938        for i in 0..50 {
1939            state.buckets.items.push(S3Bucket {
1940                name: format!("bucket{}", i),
1941                region: "us-east-1".to_string(),
1942                creation_date: String::new(),
1943            });
1944        }
1945
1946        state.bucket_visible_rows.set(10);
1947        state.bucket_scroll_offset = 0;
1948        state.selected_row = 5;
1949
1950        // Simulate Ctrl+D (jump down 10)
1951        let total_rows = state.buckets.items.len();
1952        state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
1953
1954        // Adjust scroll offset
1955        let visible_rows = state.bucket_visible_rows.get();
1956        if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1957            state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1958        }
1959
1960        // Selection should be at row 15
1961        assert_eq!(state.selected_row, 15);
1962        // Scroll offset should be 6 (15 - 10 + 1)
1963        assert_eq!(state.bucket_scroll_offset, 6);
1964        // Selection should be visible
1965        assert!(state.selected_row >= state.bucket_scroll_offset);
1966        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1967    }
1968
1969    #[test]
1970    fn test_ctrl_u_adjusts_scroll_offset() {
1971        let mut state = State::new();
1972        state.buckets.items = vec![];
1973        for i in 0..50 {
1974            state.buckets.items.push(S3Bucket {
1975                name: format!("bucket{}", i),
1976                region: "us-east-1".to_string(),
1977                creation_date: String::new(),
1978            });
1979        }
1980
1981        state.bucket_visible_rows.set(10);
1982        state.bucket_scroll_offset = 10;
1983        state.selected_row = 15;
1984
1985        // Simulate Ctrl+U (jump up 10)
1986        state.selected_row = state.selected_row.saturating_sub(10);
1987
1988        // Adjust scroll offset
1989        if state.selected_row < state.bucket_scroll_offset {
1990            state.bucket_scroll_offset = state.selected_row;
1991        }
1992
1993        // Selection should be at row 5
1994        assert_eq!(state.selected_row, 5);
1995        // Scroll offset should be 5
1996        assert_eq!(state.bucket_scroll_offset, 5);
1997        // Selection should be visible
1998        assert!(state.selected_row >= state.bucket_scroll_offset);
1999    }
2000
2001    #[test]
2002    fn test_ctrl_d_clamps_to_max_rows() {
2003        let mut state = State::new();
2004        state.buckets.items = vec![];
2005        for i in 0..20 {
2006            state.buckets.items.push(S3Bucket {
2007                name: format!("bucket{}", i),
2008                region: "us-east-1".to_string(),
2009                creation_date: String::new(),
2010            });
2011        }
2012
2013        state.bucket_visible_rows.set(10);
2014        state.bucket_scroll_offset = 5;
2015        state.selected_row = 15;
2016
2017        // Simulate Ctrl+D (jump down 10) - should clamp to max
2018        let total_rows = state.buckets.items.len();
2019        state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
2020
2021        // Selection should be clamped to 19 (last row)
2022        assert_eq!(state.selected_row, 19);
2023    }
2024
2025    #[test]
2026    fn test_rounded_block_with_custom_border_style() {
2027        use ratatui::prelude::Rect;
2028        let block = rounded_block()
2029            .title(format_title("Properties"))
2030            .border_style(active_border());
2031        let area = Rect::new(0, 0, 70, 12);
2032        let inner = block.inner(area);
2033        assert_eq!(inner.width, 68);
2034        assert_eq!(inner.height, 10);
2035    }
2036
2037    #[test]
2038    fn test_column_width_accounts_for_header_text() {
2039        // Test that column widths are at least as wide as header text
2040        let header_name = "Name";
2041        let header_region = "Region";
2042        let header_date = "Creation date";
2043
2044        // First column has no separator
2045        assert!(header_name.len() >= 4);
2046
2047        // Other columns need space for "⋮ " separator (2 chars)
2048        let region_with_sep = header_region.len() + 2;
2049        let date_with_sep = header_date.len() + 2;
2050
2051        assert!(region_with_sep >= header_region.len());
2052        assert!(date_with_sep >= header_date.len());
2053    }
2054
2055    #[test]
2056    fn test_title_formatting_no_double_dash() {
2057        // Test that title formatting is correct
2058        let title = format!("{} ({})", "Directory buckets", 0);
2059        let formatted = format_title(&title);
2060
2061        // Should be "─ Directory buckets (0) ─"
2062        assert_eq!(formatted, "─ Directory buckets (0) ─");
2063    }
2064
2065    #[test]
2066    fn test_collapse_moves_to_parent() {
2067        // Test that collapsing an expanded node moves selection to parent
2068        let mut state = State::new();
2069        state.buckets.items = vec![S3Bucket {
2070            name: "bucket1".to_string(),
2071            region: "us-east-1".to_string(),
2072            creation_date: String::new(),
2073        }];
2074
2075        // Expand bucket with a folder
2076        state.expanded_prefixes.insert("bucket1".to_string());
2077        state.bucket_preview.insert(
2078            "bucket1".to_string(),
2079            vec![S3Object {
2080                key: "folder1/".to_string(),
2081                is_prefix: true,
2082                size: 0,
2083                last_modified: String::new(),
2084                storage_class: String::new(),
2085            }],
2086        );
2087
2088        // Expand the folder
2089        state.expanded_prefixes.insert("folder1/".to_string());
2090        state.prefix_preview.insert(
2091            "folder1/".to_string(),
2092            vec![S3Object {
2093                key: "folder1/file.txt".to_string(),
2094                is_prefix: false,
2095                size: 100,
2096                last_modified: String::new(),
2097                storage_class: String::new(),
2098            }],
2099        );
2100
2101        // Select the expanded folder (row 1)
2102        state.selected_row = 1;
2103
2104        // Verify folder is expanded
2105        assert!(state.expanded_prefixes.contains("folder1/"));
2106
2107        // After collapse, selection should move to parent (bucket at row 0)
2108        // This is tested in app.rs Left key handling
2109    }
2110
2111    #[test]
2112    fn test_hierarchy_collapse_sequence() {
2113        // Test that pressing left multiple times collapses hierarchy level by level
2114        let mut state = State::new();
2115        state.buckets.items = vec![S3Bucket {
2116            name: "bucket1".to_string(),
2117            region: "us-east-1".to_string(),
2118            creation_date: String::new(),
2119        }];
2120
2121        // Create 3-level hierarchy
2122        state.expanded_prefixes.insert("bucket1".to_string());
2123        state.bucket_preview.insert(
2124            "bucket1".to_string(),
2125            vec![S3Object {
2126                key: "level1/".to_string(),
2127                is_prefix: true,
2128                size: 0,
2129                last_modified: String::new(),
2130                storage_class: String::new(),
2131            }],
2132        );
2133
2134        state.expanded_prefixes.insert("level1/".to_string());
2135        state.prefix_preview.insert(
2136            "level1/".to_string(),
2137            vec![S3Object {
2138                key: "level1/level2/".to_string(),
2139                is_prefix: true,
2140                size: 0,
2141                last_modified: String::new(),
2142                storage_class: String::new(),
2143            }],
2144        );
2145
2146        state.expanded_prefixes.insert("level1/level2/".to_string());
2147        state.prefix_preview.insert(
2148            "level1/level2/".to_string(),
2149            vec![S3Object {
2150                key: "level1/level2/file.txt".to_string(),
2151                is_prefix: false,
2152                size: 100,
2153                last_modified: String::new(),
2154                storage_class: String::new(),
2155            }],
2156        );
2157
2158        // All 3 levels should be expanded
2159        assert_eq!(state.expanded_prefixes.len(), 3);
2160
2161        // After 3 left presses, all should be collapsed
2162        // This is tested in app.rs Left key handling
2163    }
2164
2165    #[test]
2166    fn test_objects_collapse_jumps_to_parent() {
2167        // Test that collapsing an expanded prefix in objects view jumps to parent
2168        let mut state = State::new();
2169        state.current_bucket = Some("test-bucket".to_string());
2170
2171        // Create hierarchy: folder1/ -> folder2/ -> file.txt
2172        state.objects = vec![S3Object {
2173            key: "folder1/".to_string(),
2174            is_prefix: true,
2175            size: 0,
2176            last_modified: String::new(),
2177            storage_class: String::new(),
2178        }];
2179
2180        state.expanded_prefixes.insert("folder1/".to_string());
2181        state.prefix_preview.insert(
2182            "folder1/".to_string(),
2183            vec![S3Object {
2184                key: "folder1/folder2/".to_string(),
2185                is_prefix: true,
2186                size: 0,
2187                last_modified: String::new(),
2188                storage_class: String::new(),
2189            }],
2190        );
2191
2192        state
2193            .expanded_prefixes
2194            .insert("folder1/folder2/".to_string());
2195        state.prefix_preview.insert(
2196            "folder1/folder2/".to_string(),
2197            vec![S3Object {
2198                key: "folder1/folder2/file.txt".to_string(),
2199                is_prefix: false,
2200                size: 100,
2201                last_modified: String::new(),
2202                storage_class: String::new(),
2203            }],
2204        );
2205
2206        // Select folder2 (visual index 1: folder1, then folder2)
2207        state.selected_object = 1;
2208
2209        // Verify folder2 is expanded
2210        assert!(state.expanded_prefixes.contains("folder1/folder2/"));
2211
2212        // After collapse, folder2 should be collapsed and selection should jump to parent (folder1)
2213        // This behavior is tested in app.rs prev_pane function
2214        // Expected: expanded_prefixes.remove("folder1/folder2/") and selected_object = 0
2215    }
2216}