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                && (app.s3_state.bucket_preview.contains_key(&bucket.name)
560                    || app.s3_state.bucket_errors.contains_key(&bucket.name));
561            let expand_indicator = if is_expanded {
562                format!("{} ", CURSOR_EXPANDED)
563            } else {
564                format!("{} ", CURSOR_COLLAPSED)
565            };
566
567            // Format date as YYYY-MM-DD HH:MM:SS (UTC)
568            let formatted_date = if bucket.creation_date.contains('T') {
569                // Parse ISO 8601 format: 2025-05-07T12:34:47Z
570                let parts: Vec<&str> = bucket.creation_date.split('T').collect();
571                if parts.len() == 2 {
572                    let date = parts[0];
573                    let time = parts[1]
574                        .trim_end_matches('Z')
575                        .split('.')
576                        .next()
577                        .unwrap_or(parts[1]);
578                    format!("{} {} (UTC)", date, time)
579                } else {
580                    bucket.creation_date.clone()
581                }
582            } else {
583                bucket.creation_date.clone()
584            };
585
586            // Calculate row index for this bucket
587            fn count_expanded_children(
588                objects: &[S3Object],
589                expanded_prefixes: &std::collections::HashSet<String>,
590                prefix_preview: &std::collections::HashMap<String, Vec<S3Object>>,
591            ) -> usize {
592                let mut count = objects.len();
593                for obj in objects {
594                    if obj.is_prefix && expanded_prefixes.contains(&obj.key) {
595                        if let Some(nested) = prefix_preview.get(&obj.key) {
596                            count +=
597                                count_expanded_children(nested, expanded_prefixes, prefix_preview);
598                        }
599                    }
600                }
601                count
602            }
603
604            // Calculate row index - need to count all buckets before this one (including previous pages)
605            let mut row_idx = 0;
606            // Count all buckets on previous pages
607            for i in 0..start_idx {
608                if let Some((_, b)) = all_filtered_buckets.get(i) {
609                    row_idx += 1; // The bucket itself
610                    if app.s3_state.expanded_prefixes.contains(&b.name) {
611                        if let Some(preview) = app.s3_state.bucket_preview.get(&b.name) {
612                            row_idx += count_expanded_children(
613                                preview,
614                                &app.s3_state.expanded_prefixes,
615                                &app.s3_state.prefix_preview,
616                            );
617                        }
618                    }
619                }
620            }
621            // Count buckets on current page before this one
622            for i in 0..bucket_idx {
623                if let Some((_, b)) = filtered_buckets.get(i) {
624                    row_idx += 1; // The bucket itself
625                    if app.s3_state.expanded_prefixes.contains(&b.name) {
626                        if let Some(preview) = app.s3_state.bucket_preview.get(&b.name) {
627                            row_idx += count_expanded_children(
628                                preview,
629                                &app.s3_state.expanded_prefixes,
630                                &app.s3_state.prefix_preview,
631                            );
632                        }
633                    }
634                }
635            }
636
637            let style = if row_idx == app.s3_state.selected_row {
638                Style::default().bg(Color::DarkGray)
639            } else {
640                Style::default()
641            };
642
643            let cells: Vec<Cell> = app
644                .s3_bucket_visible_column_ids
645                .iter()
646                .enumerate()
647                .filter_map(|(i, col_id)| {
648                    BucketColumn::from_id(col_id).map(|col| {
649                        let content = match col {
650                            BucketColumn::Name => {
651                                format!("{}🪣 {}", expand_indicator, bucket.name)
652                            }
653                            BucketColumn::Region => {
654                                if bucket.region.is_empty() {
655                                    "?".to_string()
656                                } else {
657                                    bucket.region.clone()
658                                }
659                            }
660                            BucketColumn::CreationDate => formatted_date.clone(),
661                        };
662                        let cell_content = if i > 0 {
663                            format!("⋮ {}", content)
664                        } else {
665                            content
666                        };
667                        Cell::from(cell_content)
668                    })
669                })
670                .collect();
671
672            let mut result = vec![Row::new(cells).height(1).style(style)];
673            let mut child_row_idx = row_idx + 1;
674
675            if is_expanded {
676                if let Some(error) = app.s3_state.bucket_errors.get(&bucket.name) {
677                    // Show error message in expanded rows (non-selectable)
678                    // Split error into multiple lines if needed
679                    let max_width = 120;
680                    let error_lines: Vec<String> = if error.len() > max_width {
681                        error
682                            .as_bytes()
683                            .chunks(max_width)
684                            .map(|chunk| String::from_utf8_lossy(chunk).to_string())
685                            .collect()
686                    } else {
687                        vec![error.clone()]
688                    };
689
690                    for (line_idx, error_line) in error_lines.iter().enumerate() {
691                        let error_cells: Vec<Cell> = app
692                            .s3_bucket_visible_column_ids
693                            .iter()
694                            .enumerate()
695                            .map(|(i, _col)| {
696                                if i == 0 {
697                                    if line_idx == 0 {
698                                        Cell::from(format!("  ⚠️  {}", error_line))
699                                            .style(red_text())
700                                    } else {
701                                        Cell::from(format!("     {}", error_line)).style(red_text())
702                                    }
703                                } else {
704                                    Cell::from("")
705                                }
706                            })
707                            .collect();
708                        result.push(Row::new(error_cells).height(1));
709                    }
710                    // Don't increment child_row_idx for error rows - they're not selectable
711                } else if let Some(preview) = app.s3_state.bucket_preview.get(&bucket.name) {
712                    // Recursive function to render objects at any depth
713                    fn render_objects_recursive<'a>(
714                        objects: &'a [S3Object],
715                        app: &'a App,
716                        child_row_idx: &mut usize,
717                        result: &mut Vec<Row<'a>>,
718                        parent_key: &str,
719                        is_last: &[bool],
720                    ) {
721                        for (idx, obj) in objects.iter().enumerate() {
722                            let is_last_item = idx == objects.len() - 1;
723                            let obj_is_expanded = app.s3_state.expanded_prefixes.contains(&obj.key);
724
725                            // Build prefix with tree characters
726                            let mut prefix = String::new();
727                            for &last in is_last.iter() {
728                                prefix.push_str(if last { "  " } else { "│ " });
729                            }
730
731                            let tree_char = if is_last_item { "╰─" } else { "├─" };
732                            let expand_char = if obj.is_prefix {
733                                if obj_is_expanded {
734                                    CURSOR_EXPANDED
735                                } else {
736                                    CURSOR_COLLAPSED
737                                }
738                            } else {
739                                ""
740                            };
741
742                            let icon = if obj.is_prefix { "📁" } else { "📄" };
743                            let display_key = obj.key.strip_prefix(parent_key).unwrap_or(&obj.key);
744
745                            let child_style = if *child_row_idx == app.s3_state.selected_row {
746                                Style::default().bg(Color::DarkGray)
747                            } else {
748                                Style::default()
749                            };
750
751                            let formatted_date = format_iso_timestamp(&obj.last_modified);
752
753                            let child_cells: Vec<Cell> = app
754                                .s3_bucket_visible_column_ids
755                                .iter()
756                                .enumerate()
757                                .filter_map(|(i, col_id)| {
758                                    BucketColumn::from_id(col_id).map(|col| {
759                                        let content = match col {
760                                            BucketColumn::Name => format!(
761                                                "{}{}{} {} {}",
762                                                prefix, tree_char, expand_char, icon, display_key
763                                            ),
764                                            BucketColumn::Region => String::new(),
765                                            BucketColumn::CreationDate => formatted_date.clone(),
766                                        };
767                                        if i > 0 {
768                                            Cell::from(format!("⋮ {}", content))
769                                        } else {
770                                            Cell::from(content)
771                                        }
772                                    })
773                                })
774                                .collect();
775                            result.push(Row::new(child_cells).style(child_style));
776                            *child_row_idx += 1;
777
778                            // Recursively render nested items if expanded
779                            if obj.is_prefix && obj_is_expanded {
780                                if let Some(nested_preview) =
781                                    app.s3_state.prefix_preview.get(&obj.key)
782                                {
783                                    let mut new_is_last = is_last.to_vec();
784                                    new_is_last.push(is_last_item);
785                                    render_objects_recursive(
786                                        nested_preview,
787                                        app,
788                                        child_row_idx,
789                                        result,
790                                        &obj.key,
791                                        &new_is_last,
792                                    );
793                                }
794                            }
795                        }
796                    }
797
798                    render_objects_recursive(
799                        preview,
800                        app,
801                        &mut child_row_idx,
802                        &mut result,
803                        "",
804                        &[],
805                    );
806                }
807            }
808
809            result
810        })
811        .skip(
812            app.s3_state
813                .bucket_scroll_offset
814                .saturating_sub(first_bucket_row_idx),
815        )
816        .take(app.s3_state.bucket_visible_rows.get())
817        .collect();
818
819    let widths: Vec<Constraint> = app
820        .s3_bucket_visible_column_ids
821        .iter()
822        .enumerate()
823        .filter_map(|(i, col_id)| {
824            BucketColumn::from_id(col_id).map(|col| {
825                let base_width = match col {
826                    BucketColumn::Name => max_name_width,
827                    BucketColumn::Region => max_region_width,
828                    BucketColumn::CreationDate => max_date_width,
829                };
830                // Add 2 for "⋮ " separator on columns after the first
831                let width = if i > 0 { base_width + 2 } else { base_width };
832                Constraint::Length(width as u16)
833            })
834        })
835        .collect();
836
837    let is_active = app.mode != Mode::ColumnSelector;
838    let border_color = if is_active {
839        Color::Green
840    } else {
841        Color::White
842    };
843
844    let table = Table::new(rows, widths)
845        .header(header)
846        .column_spacing(1)
847        .block(titled_block(title).border_style(Style::default().fg(border_color)));
848
849    frame.render_widget(table, chunks[2]);
850
851    // Render scrollbar if content exceeds visible area
852    let total_rows = app.s3_state.calculate_total_bucket_rows();
853    let visible_rows = chunks[2].height.saturating_sub(3) as usize; // Subtract borders and header
854    if total_rows > visible_rows {
855        render_scrollbar(
856            frame,
857            chunks[2].inner(Margin {
858                vertical: 1,
859                horizontal: 0,
860            }),
861            total_rows,
862            app.s3_state.selected_row,
863        );
864    }
865}
866
867fn render_objects(frame: &mut Frame, app: &App, area: Rect) {
868    let show_filter = app.s3_state.object_tab == ObjectTab::Objects;
869
870    let chunks = if show_filter {
871        vertical(
872            [
873                Constraint::Length(1), // Tabs
874                Constraint::Length(3), // Filter (1 line + borders)
875                Constraint::Min(0),    // Content
876            ],
877            area,
878        )
879    } else {
880        vertical(
881            [
882                Constraint::Length(1), // Tabs
883                Constraint::Min(0),    // Content (no filter)
884            ],
885            area,
886        )
887    };
888
889    // Update visible rows based on actual content area
890    let content_area_idx = if show_filter { 2 } else { 1 };
891    let visible_rows = chunks[content_area_idx].height.saturating_sub(3) as usize;
892    app.s3_state.object_visible_rows.set(visible_rows);
893
894    // Tabs
895    let available_tabs = if app.s3_state.prefix_stack.is_empty() {
896        // At bucket root - show all tabs
897        ObjectTab::all()
898    } else {
899        // In a prefix - only Objects and Properties
900        vec![ObjectTab::Objects, ObjectTab::Properties]
901    };
902
903    let tab_tuples: Vec<(&str, ObjectTab)> = available_tabs
904        .iter()
905        .map(|tab| (tab.name(), *tab))
906        .collect();
907
908    frame.render_widget(Clear, chunks[0]);
909    render_tabs(frame, chunks[0], &tab_tuples, &app.s3_state.object_tab);
910
911    // Filter (only for Objects tab)
912    if app.s3_state.object_tab == ObjectTab::Objects {
913        let cursor = get_cursor(app.mode == Mode::FilterInput);
914        let filter_text = if app.s3_state.object_filter.is_empty() && app.mode != Mode::FilterInput
915        {
916            vec![
917                Span::styled(
918                    "Find objects by prefix",
919                    Style::default().fg(Color::DarkGray),
920                ),
921                Span::styled(cursor, Style::default().fg(Color::Yellow)),
922            ]
923        } else {
924            vec![
925                Span::raw(&app.s3_state.object_filter),
926                Span::styled(cursor, Style::default().fg(Color::Yellow)),
927            ]
928        };
929        let filter = filter_area(filter_text, app.mode == Mode::FilterInput);
930        frame.render_widget(filter, chunks[1]);
931    }
932
933    // Render content based on selected tab
934    let content_idx = if show_filter { 2 } else { 1 };
935    match app.s3_state.object_tab {
936        ObjectTab::Objects => render_objects_table(frame, app, chunks[content_idx]),
937        ObjectTab::Properties => render_bucket_properties(frame, app, chunks[content_idx]),
938        _ => {
939            // Placeholder for other tabs
940            let placeholder =
941                Paragraph::new(format!("{} - Coming soon", app.s3_state.object_tab.name()))
942                    .block(rounded_block().border_style(active_border()))
943                    .style(Style::default().fg(Color::Gray));
944            frame.render_widget(placeholder, chunks[content_idx]);
945        }
946    }
947}
948
949fn render_objects_table(frame: &mut Frame, app: &App, area: Rect) {
950    // Filter objects by prefix
951    let filtered_objects: Vec<_> = app
952        .s3_state
953        .objects
954        .iter()
955        .enumerate()
956        .filter(|(_, obj)| {
957            if app.s3_state.object_filter.is_empty() {
958                true
959            } else {
960                let name = obj.key.trim_start_matches(
961                    &app.s3_state
962                        .prefix_stack
963                        .last()
964                        .cloned()
965                        .unwrap_or_default(),
966                );
967                name.to_lowercase()
968                    .contains(&app.s3_state.object_filter.to_lowercase())
969            }
970        })
971        .collect();
972
973    let count = filtered_objects.len();
974    let title = format_title(&format!("Objects ({})", count));
975
976    // Calculate max name width
977    let max_name_width = filtered_objects
978        .iter()
979        .map(|(_, obj)| {
980            let name = obj.key.trim_start_matches(
981                &app.s3_state
982                    .prefix_stack
983                    .last()
984                    .cloned()
985                    .unwrap_or_default(),
986            );
987            name.len() + 4 // +4 for icon and expand indicator
988        })
989        .max()
990        .unwrap_or(30)
991        .max(30) as u16;
992
993    let rows: Vec<Row> = filtered_objects
994        .iter()
995        .flat_map(|(idx, obj)| {
996            let icon = if obj.is_prefix { "📁" } else { "📄" };
997
998            // Add expand indicator for prefixes
999            let expand_indicator = if obj.is_prefix {
1000                if app.s3_state.expanded_prefixes.contains(&obj.key) {
1001                    format!("{} ", CURSOR_EXPANDED)
1002                } else {
1003                    format!("{} ", CURSOR_COLLAPSED)
1004                }
1005            } else {
1006                String::new()
1007            };
1008
1009            let name = obj.key.trim_start_matches(
1010                &app.s3_state
1011                    .prefix_stack
1012                    .last()
1013                    .cloned()
1014                    .unwrap_or_default(),
1015            );
1016            let display_name = format!("{}{} {}", expand_indicator, icon, name);
1017            let obj_type = if obj.is_prefix { "Folder" } else { "File" };
1018            let size = if obj.is_prefix {
1019                String::new()
1020            } else {
1021                format_bytes(obj.size)
1022            };
1023
1024            // Format datetime with (UTC)
1025            let datetime = format_iso_timestamp(&obj.last_modified);
1026
1027            // Format storage class
1028            let storage = if obj.storage_class.is_empty() {
1029                String::new()
1030            } else {
1031                obj.storage_class
1032                    .chars()
1033                    .next()
1034                    .unwrap()
1035                    .to_uppercase()
1036                    .to_string()
1037                    + &obj.storage_class[1..].to_lowercase()
1038            };
1039
1040            // Calculate row index including nested items
1041            let mut row_idx = *idx;
1042            for i in 0..*idx {
1043                if let Some(prev_obj) = app.s3_state.objects.get(i) {
1044                    if prev_obj.is_prefix && app.s3_state.expanded_prefixes.contains(&prev_obj.key)
1045                    {
1046                        if let Some(preview) = app.s3_state.prefix_preview.get(&prev_obj.key) {
1047                            row_idx += preview.len();
1048                        }
1049                    }
1050                }
1051            }
1052
1053            let style = if row_idx == app.s3_state.selected_object {
1054                Style::default().bg(Color::DarkGray)
1055            } else {
1056                Style::default()
1057            };
1058
1059            let mut result = vec![Row::new(vec![
1060                Cell::from(display_name),
1061                Cell::from(format!("⋮ {}", obj_type)),
1062                Cell::from(format!("⋮ {}", datetime)),
1063                Cell::from(format!("⋮ {}", size)),
1064                Cell::from(format!("⋮ {}", storage)),
1065            ])
1066            .style(style)];
1067
1068            let mut child_row_idx = row_idx + 1;
1069
1070            if obj.is_prefix && app.s3_state.expanded_prefixes.contains(&obj.key) {
1071                if let Some(preview) = app.s3_state.prefix_preview.get(&obj.key) {
1072                    // Recursive function to render nested objects
1073                    fn render_nested_objects<'a>(
1074                        objects: &'a [S3Object],
1075                        app: &'a App,
1076                        child_row_idx: &mut usize,
1077                        result: &mut Vec<Row<'a>>,
1078                        parent_key: &str,
1079                        is_last: &[bool],
1080                    ) {
1081                        for (child_idx, preview_obj) in objects.iter().enumerate() {
1082                            let is_last_child = child_idx == objects.len() - 1;
1083                            let obj_is_expanded =
1084                                app.s3_state.expanded_prefixes.contains(&preview_obj.key);
1085
1086                            // Build prefix with tree characters
1087                            let mut prefix = String::new();
1088                            for &last in is_last.iter() {
1089                                prefix.push_str(if last { "  " } else { "│ " });
1090                            }
1091
1092                            let tree_char = if is_last_child { "╰─" } else { "├─" };
1093                            let child_expand = if preview_obj.is_prefix {
1094                                if obj_is_expanded {
1095                                    CURSOR_EXPANDED
1096                                } else {
1097                                    CURSOR_COLLAPSED
1098                                }
1099                            } else {
1100                                ""
1101                            };
1102                            let child_icon = if preview_obj.is_prefix {
1103                                "📁"
1104                            } else {
1105                                "📄"
1106                            };
1107                            let child_name = preview_obj
1108                                .key
1109                                .strip_prefix(parent_key)
1110                                .unwrap_or(&preview_obj.key);
1111
1112                            let child_type = if preview_obj.is_prefix {
1113                                "Folder"
1114                            } else {
1115                                "File"
1116                            };
1117                            let child_size = if preview_obj.is_prefix {
1118                                String::new()
1119                            } else {
1120                                format_bytes(preview_obj.size)
1121                            };
1122                            let child_datetime = format_iso_timestamp(&preview_obj.last_modified);
1123                            let child_storage = if preview_obj.storage_class.is_empty() {
1124                                String::new()
1125                            } else {
1126                                preview_obj
1127                                    .storage_class
1128                                    .chars()
1129                                    .next()
1130                                    .unwrap()
1131                                    .to_uppercase()
1132                                    .to_string()
1133                                    + &preview_obj.storage_class[1..].to_lowercase()
1134                            };
1135
1136                            let child_style = if *child_row_idx == app.s3_state.selected_object {
1137                                Style::default().bg(Color::DarkGray)
1138                            } else {
1139                                Style::default()
1140                            };
1141
1142                            result.push(
1143                                Row::new(vec![
1144                                    Cell::from(format!(
1145                                        "{}{}{} {} {}",
1146                                        prefix, tree_char, child_expand, child_icon, child_name
1147                                    )),
1148                                    Cell::from(format!("⋮ {}", child_type)),
1149                                    Cell::from(format!("⋮ {}", child_datetime)),
1150                                    Cell::from(format!("⋮ {}", child_size)),
1151                                    Cell::from(format!("⋮ {}", child_storage)),
1152                                ])
1153                                .style(child_style),
1154                            );
1155                            *child_row_idx += 1;
1156
1157                            // Recursively render nested children
1158                            if preview_obj.is_prefix && obj_is_expanded {
1159                                if let Some(nested_preview) =
1160                                    app.s3_state.prefix_preview.get(&preview_obj.key)
1161                                {
1162                                    let mut new_is_last = is_last.to_vec();
1163                                    new_is_last.push(is_last_child);
1164                                    render_nested_objects(
1165                                        nested_preview,
1166                                        app,
1167                                        child_row_idx,
1168                                        result,
1169                                        &preview_obj.key,
1170                                        &new_is_last,
1171                                    );
1172                                }
1173                            }
1174                        }
1175                    }
1176
1177                    render_nested_objects(
1178                        preview,
1179                        app,
1180                        &mut child_row_idx,
1181                        &mut result,
1182                        &obj.key,
1183                        &[],
1184                    );
1185                }
1186            }
1187
1188            result
1189        })
1190        .skip(app.s3_state.object_scroll_offset)
1191        .take(app.s3_state.object_visible_rows.get())
1192        .collect();
1193
1194    crate::ui::table::render_tree_table(
1195        frame,
1196        area,
1197        title,
1198        vec!["Name", "Type", "Last modified", "Size", "Storage class"],
1199        rows,
1200        vec![
1201            Constraint::Length(max_name_width),
1202            Constraint::Length(10),
1203            Constraint::Length(UTC_TIMESTAMP_WIDTH),
1204            Constraint::Length(12),
1205            Constraint::Length(15),
1206        ],
1207        true,
1208    );
1209
1210    // Render scrollbar if content exceeds visible area
1211    let total_rows = app.s3_state.calculate_total_object_rows();
1212    let visible_rows = area.height.saturating_sub(3) as usize;
1213    if total_rows > visible_rows {
1214        render_scrollbar(
1215            frame,
1216            area.inner(Margin {
1217                vertical: 1,
1218                horizontal: 0,
1219            }),
1220            total_rows,
1221            app.s3_state.selected_object,
1222        );
1223    }
1224}
1225
1226fn render_bucket_properties(frame: &mut Frame, app: &App, area: Rect) {
1227    let bucket_name = app.s3_state.current_bucket.as_ref().unwrap();
1228    let bucket = app
1229        .s3_state
1230        .buckets
1231        .items
1232        .iter()
1233        .find(|b| &b.name == bucket_name);
1234
1235    let mut lines = vec![];
1236
1237    let block = rounded_block()
1238        .title(format_title("Properties"))
1239        .border_style(active_border());
1240    let inner = block.inner(area);
1241
1242    // Bucket overview
1243    lines.push(section_header("Bucket overview", inner.width));
1244    if let Some(b) = bucket {
1245        let region = if b.region.is_empty() { "?" } else { &b.region };
1246        let formatted_date = if b.creation_date.contains('T') {
1247            let parts: Vec<&str> = b.creation_date.split('T').collect();
1248            if parts.len() == 2 {
1249                format!(
1250                    "{} {} (UTC)",
1251                    parts[0],
1252                    parts[1]
1253                        .trim_end_matches('Z')
1254                        .split('.')
1255                        .next()
1256                        .unwrap_or(parts[1])
1257                )
1258            } else {
1259                b.creation_date.clone()
1260            }
1261        } else {
1262            b.creation_date.clone()
1263        };
1264        lines.push(Line::from(vec![
1265            Span::styled(
1266                "AWS Region: ",
1267                Style::default().add_modifier(Modifier::BOLD),
1268            ),
1269            Span::raw(region),
1270        ]));
1271        lines.push(Line::from(vec![
1272            Span::styled(
1273                "Amazon Resource Name (ARN): ",
1274                Style::default().add_modifier(Modifier::BOLD),
1275            ),
1276            Span::raw(format!("arn:aws:s3:::{}", bucket_name)),
1277        ]));
1278        lines.push(Line::from(vec![
1279            Span::styled(
1280                "Creation date: ",
1281                Style::default().add_modifier(Modifier::BOLD),
1282            ),
1283            Span::raw(formatted_date),
1284        ]));
1285    }
1286    lines.push(Line::from(""));
1287
1288    // Tags
1289    lines.push(section_header("Tags (0)", inner.width));
1290    lines.push(Line::from("No tags associated with this resource."));
1291    lines.push(Line::from(""));
1292
1293    // Default encryption
1294    lines.push(section_header("Default encryption", inner.width));
1295    lines.push(Line::from(vec![
1296        Span::styled(
1297            "Encryption type: ",
1298            Style::default().add_modifier(Modifier::BOLD),
1299        ),
1300        Span::raw("Server-side encryption with Amazon S3 managed keys (SSE-S3)"),
1301    ]));
1302    lines.push(Line::from(vec![
1303        Span::styled(
1304            "Bucket Key: ",
1305            Style::default().add_modifier(Modifier::BOLD),
1306        ),
1307        Span::raw("Disabled"),
1308    ]));
1309    lines.push(Line::from(""));
1310
1311    // Server access logging
1312    lines.push(section_header("Server access logging", inner.width));
1313    lines.push(Line::from("Disabled"));
1314    lines.push(Line::from(""));
1315
1316    // CloudTrail
1317    lines.push(section_header("AWS CloudTrail data events", inner.width));
1318    lines.push(Line::from("Configure in CloudTrail console"));
1319    lines.push(Line::from(""));
1320
1321    // EventBridge
1322    lines.push(section_header("Amazon EventBridge", inner.width));
1323    lines.push(Line::from(vec![
1324        Span::styled(
1325            "Send notifications to Amazon EventBridge: ",
1326            Style::default().add_modifier(Modifier::BOLD),
1327        ),
1328        Span::raw("Off"),
1329    ]));
1330    lines.push(Line::from(""));
1331
1332    // Transfer acceleration
1333    lines.push(section_header("Transfer acceleration", inner.width));
1334    lines.push(Line::from("Disabled"));
1335    lines.push(Line::from(""));
1336
1337    // Object Lock
1338    lines.push(section_header("Object Lock", inner.width));
1339    lines.push(Line::from("Disabled"));
1340    lines.push(Line::from(""));
1341
1342    // Requester pays
1343    lines.push(section_header("Requester pays", inner.width));
1344    lines.push(Line::from("Disabled"));
1345    lines.push(Line::from(""));
1346
1347    // Static website hosting
1348    lines.push(section_header("Static website hosting", inner.width));
1349    lines.push(Line::from("Disabled"));
1350
1351    let paragraph = Paragraph::new(lines)
1352        .block(block)
1353        .wrap(Wrap { trim: false })
1354        .scroll((app.s3_state.properties_scroll, 0));
1355
1356    frame.render_widget(paragraph, area);
1357
1358    // Render scrollbar if needed
1359    let content_height = 40; // Approximate line count
1360    if content_height > area.height.saturating_sub(2) {
1361        render_scrollbar(
1362            frame,
1363            area.inner(Margin {
1364                vertical: 1,
1365                horizontal: 0,
1366            }),
1367            content_height as usize,
1368            app.s3_state.properties_scroll as usize,
1369        );
1370    }
1371}
1372
1373// S3-specific helper functions
1374pub async fn load_s3_buckets(app: &mut App) -> anyhow::Result<()> {
1375    let buckets = app.s3_client.list_buckets().await?;
1376    app.s3_state.buckets.items = buckets
1377        .into_iter()
1378        .map(|(name, region, date)| AppS3Bucket {
1379            name,
1380            region,
1381            creation_date: date,
1382        })
1383        .collect();
1384    Ok(())
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389    use super::*;
1390
1391    #[test]
1392    fn test_calculate_total_bucket_rows_no_expansion() {
1393        let mut state = State::new();
1394        state.buckets.items = vec![
1395            S3Bucket {
1396                name: "bucket1".to_string(),
1397                region: "us-east-1".to_string(),
1398                creation_date: String::new(),
1399            },
1400            S3Bucket {
1401                name: "bucket2".to_string(),
1402                region: "us-west-2".to_string(),
1403                creation_date: String::new(),
1404            },
1405        ];
1406
1407        assert_eq!(state.calculate_total_bucket_rows(), 2);
1408    }
1409
1410    #[test]
1411    fn test_calculate_total_bucket_rows_with_expansion() {
1412        let mut state = State::new();
1413        state.buckets.items = vec![S3Bucket {
1414            name: "bucket1".to_string(),
1415            region: "us-east-1".to_string(),
1416            creation_date: String::new(),
1417        }];
1418
1419        // Expand bucket1
1420        state.expanded_prefixes.insert("bucket1".to_string());
1421        state.bucket_preview.insert(
1422            "bucket1".to_string(),
1423            vec![
1424                S3Object {
1425                    key: "file1.txt".to_string(),
1426                    is_prefix: false,
1427                    size: 100,
1428                    last_modified: String::new(),
1429                    storage_class: String::new(),
1430                },
1431                S3Object {
1432                    key: "folder1/".to_string(),
1433                    is_prefix: true,
1434                    size: 0,
1435                    last_modified: String::new(),
1436                    storage_class: String::new(),
1437                },
1438            ],
1439        );
1440
1441        // 1 bucket + 2 preview items
1442        assert_eq!(state.calculate_total_bucket_rows(), 3);
1443    }
1444
1445    #[test]
1446    fn test_calculate_total_bucket_rows_nested_expansion() {
1447        let mut state = State::new();
1448        state.buckets.items = vec![S3Bucket {
1449            name: "bucket1".to_string(),
1450            region: "us-east-1".to_string(),
1451            creation_date: String::new(),
1452        }];
1453
1454        // Expand bucket1
1455        state.expanded_prefixes.insert("bucket1".to_string());
1456        state.bucket_preview.insert(
1457            "bucket1".to_string(),
1458            vec![S3Object {
1459                key: "folder1/".to_string(),
1460                is_prefix: true,
1461                size: 0,
1462                last_modified: String::new(),
1463                storage_class: String::new(),
1464            }],
1465        );
1466
1467        // Expand folder1
1468        state.expanded_prefixes.insert("folder1/".to_string());
1469        state.prefix_preview.insert(
1470            "folder1/".to_string(),
1471            vec![
1472                S3Object {
1473                    key: "folder1/file1.txt".to_string(),
1474                    is_prefix: false,
1475                    size: 100,
1476                    last_modified: String::new(),
1477                    storage_class: String::new(),
1478                },
1479                S3Object {
1480                    key: "folder1/file2.txt".to_string(),
1481                    is_prefix: false,
1482                    size: 200,
1483                    last_modified: String::new(),
1484                    storage_class: String::new(),
1485                },
1486            ],
1487        );
1488
1489        // 1 bucket + 1 folder + 2 nested files
1490        assert_eq!(state.calculate_total_bucket_rows(), 4);
1491    }
1492
1493    #[test]
1494    fn test_calculate_total_bucket_rows_deeply_nested() {
1495        let mut state = State::new();
1496        state.buckets.items = vec![S3Bucket {
1497            name: "bucket1".to_string(),
1498            region: "us-east-1".to_string(),
1499            creation_date: String::new(),
1500        }];
1501
1502        // Expand bucket1 with folder1
1503        state.expanded_prefixes.insert("bucket1".to_string());
1504        state.bucket_preview.insert(
1505            "bucket1".to_string(),
1506            vec![S3Object {
1507                key: "folder1/".to_string(),
1508                is_prefix: true,
1509                size: 0,
1510                last_modified: String::new(),
1511                storage_class: String::new(),
1512            }],
1513        );
1514
1515        // Expand folder1 with folder2
1516        state.expanded_prefixes.insert("folder1/".to_string());
1517        state.prefix_preview.insert(
1518            "folder1/".to_string(),
1519            vec![S3Object {
1520                key: "folder1/folder2/".to_string(),
1521                is_prefix: true,
1522                size: 0,
1523                last_modified: String::new(),
1524                storage_class: String::new(),
1525            }],
1526        );
1527
1528        // Expand folder2 with files
1529        state
1530            .expanded_prefixes
1531            .insert("folder1/folder2/".to_string());
1532        state.prefix_preview.insert(
1533            "folder1/folder2/".to_string(),
1534            vec![
1535                S3Object {
1536                    key: "folder1/folder2/file1.txt".to_string(),
1537                    is_prefix: false,
1538                    size: 100,
1539                    last_modified: String::new(),
1540                    storage_class: String::new(),
1541                },
1542                S3Object {
1543                    key: "folder1/folder2/file2.txt".to_string(),
1544                    is_prefix: false,
1545                    size: 200,
1546                    last_modified: String::new(),
1547                    storage_class: String::new(),
1548                },
1549                S3Object {
1550                    key: "folder1/folder2/file3.txt".to_string(),
1551                    is_prefix: false,
1552                    size: 300,
1553                    last_modified: String::new(),
1554                    storage_class: String::new(),
1555                },
1556            ],
1557        );
1558
1559        // 1 bucket + 1 folder1 + 1 folder2 + 3 files = 6
1560        assert_eq!(state.calculate_total_bucket_rows(), 6);
1561    }
1562
1563    #[test]
1564    fn test_calculate_total_object_rows_no_expansion() {
1565        let mut state = State::new();
1566        state.objects = vec![
1567            S3Object {
1568                key: "folder1/".to_string(),
1569                is_prefix: true,
1570                size: 0,
1571                last_modified: String::new(),
1572                storage_class: String::new(),
1573            },
1574            S3Object {
1575                key: "folder2/".to_string(),
1576                is_prefix: true,
1577                size: 0,
1578                last_modified: String::new(),
1579                storage_class: String::new(),
1580            },
1581            S3Object {
1582                key: "file.txt".to_string(),
1583                is_prefix: false,
1584                size: 100,
1585                last_modified: String::new(),
1586                storage_class: String::new(),
1587            },
1588        ];
1589
1590        assert_eq!(state.calculate_total_object_rows(), 3);
1591    }
1592
1593    #[test]
1594    fn test_calculate_total_object_rows_with_expansion() {
1595        let mut state = State::new();
1596        state.objects = vec![
1597            S3Object {
1598                key: "folder1/".to_string(),
1599                is_prefix: true,
1600                size: 0,
1601                last_modified: String::new(),
1602                storage_class: String::new(),
1603            },
1604            S3Object {
1605                key: "file.txt".to_string(),
1606                is_prefix: false,
1607                size: 100,
1608                last_modified: String::new(),
1609                storage_class: String::new(),
1610            },
1611        ];
1612
1613        // Expand folder1
1614        state.expanded_prefixes.insert("folder1/".to_string());
1615        state.prefix_preview.insert(
1616            "folder1/".to_string(),
1617            vec![
1618                S3Object {
1619                    key: "folder1/sub1.txt".to_string(),
1620                    is_prefix: false,
1621                    size: 50,
1622                    last_modified: String::new(),
1623                    storage_class: String::new(),
1624                },
1625                S3Object {
1626                    key: "folder1/sub2.txt".to_string(),
1627                    is_prefix: false,
1628                    size: 75,
1629                    last_modified: String::new(),
1630                    storage_class: String::new(),
1631                },
1632            ],
1633        );
1634
1635        // 2 root objects + 2 children in folder1
1636        assert_eq!(state.calculate_total_object_rows(), 4);
1637    }
1638
1639    #[test]
1640    fn test_calculate_total_object_rows_nested_expansion() {
1641        let mut state = State::new();
1642        state.objects = vec![S3Object {
1643            key: "folder1/".to_string(),
1644            is_prefix: true,
1645            size: 0,
1646            last_modified: String::new(),
1647            storage_class: String::new(),
1648        }];
1649
1650        // Expand folder1
1651        state.expanded_prefixes.insert("folder1/".to_string());
1652        state.prefix_preview.insert(
1653            "folder1/".to_string(),
1654            vec![S3Object {
1655                key: "folder1/folder2/".to_string(),
1656                is_prefix: true,
1657                size: 0,
1658                last_modified: String::new(),
1659                storage_class: String::new(),
1660            }],
1661        );
1662
1663        // Expand folder2
1664        state
1665            .expanded_prefixes
1666            .insert("folder1/folder2/".to_string());
1667        state.prefix_preview.insert(
1668            "folder1/folder2/".to_string(),
1669            vec![
1670                S3Object {
1671                    key: "folder1/folder2/file1.txt".to_string(),
1672                    is_prefix: false,
1673                    size: 100,
1674                    last_modified: String::new(),
1675                    storage_class: String::new(),
1676                },
1677                S3Object {
1678                    key: "folder1/folder2/file2.txt".to_string(),
1679                    is_prefix: false,
1680                    size: 200,
1681                    last_modified: String::new(),
1682                    storage_class: String::new(),
1683                },
1684            ],
1685        );
1686
1687        // 1 root + 1 child folder + 2 nested files
1688        assert_eq!(state.calculate_total_object_rows(), 4);
1689    }
1690
1691    #[test]
1692    fn test_scrollbar_needed_when_rows_exceed_visible_area() {
1693        let visible_rows = 10;
1694        let total_rows = 15;
1695
1696        // Scrollbar should be shown when total > visible
1697        assert!(total_rows > visible_rows);
1698    }
1699
1700    #[test]
1701    fn test_scrollbar_not_needed_when_rows_fit() {
1702        let visible_rows = 20;
1703        let total_rows = 10;
1704
1705        // Scrollbar should not be shown when total <= visible
1706        assert!(total_rows <= visible_rows);
1707    }
1708
1709    #[test]
1710    fn test_scroll_offset_adjusts_when_selection_below_viewport() {
1711        let mut state = State::new();
1712        state.bucket_visible_rows.set(10);
1713        state.bucket_scroll_offset = 0;
1714        state.selected_row = 15; // Beyond visible area (0-9)
1715
1716        // Scroll offset should adjust to keep selection visible
1717        let visible_rows = state.bucket_visible_rows.get();
1718        if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1719            state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1720        }
1721
1722        assert_eq!(state.bucket_scroll_offset, 6); // 15 - 10 + 1 = 6
1723        assert!(state.selected_row >= state.bucket_scroll_offset);
1724        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1725    }
1726
1727    #[test]
1728    fn test_scroll_offset_adjusts_when_selection_above_viewport() {
1729        let mut state = State::new();
1730        state.bucket_visible_rows.set(10);
1731        state.bucket_scroll_offset = 10;
1732        state.selected_row = 5; // Above visible area (10-19)
1733
1734        // Scroll offset should adjust to keep selection visible
1735        if state.selected_row < state.bucket_scroll_offset {
1736            state.bucket_scroll_offset = state.selected_row;
1737        }
1738
1739        assert_eq!(state.bucket_scroll_offset, 5);
1740        assert!(state.selected_row >= state.bucket_scroll_offset);
1741    }
1742
1743    #[test]
1744    fn test_selection_stays_visible_during_navigation() {
1745        let mut state = State::new();
1746        state.bucket_visible_rows.set(10);
1747        state.bucket_scroll_offset = 0;
1748        state.selected_row = 0;
1749
1750        // Navigate down 15 times
1751        for _ in 0..15 {
1752            state.selected_row += 1;
1753            let visible_rows = state.bucket_visible_rows.get();
1754            if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1755                state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1756            }
1757        }
1758
1759        // Selection should be at row 15, scroll offset should keep it visible
1760        assert_eq!(state.selected_row, 15);
1761        assert_eq!(state.bucket_scroll_offset, 6);
1762        assert!(state.selected_row >= state.bucket_scroll_offset);
1763        assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1764    }
1765
1766    #[test]
1767    fn test_scroll_offset_adjusts_after_jumping_to_parent() {
1768        let mut state = State::new();
1769        state.bucket_visible_rows.set(10);
1770        state.bucket_scroll_offset = 10; // Viewing rows 10-19
1771        state.selected_row = 15; // Selected row in middle of viewport
1772
1773        // Jump to parent at row 5 (above viewport)
1774        state.selected_row = 5;
1775
1776        // Adjust scroll offset
1777        let visible_rows = state.bucket_visible_rows.get();
1778        if state.selected_row < state.bucket_scroll_offset {
1779            state.bucket_scroll_offset = state.selected_row;
1780        } else if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1781            state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1782        }
1783
1784        // Scroll offset should adjust to show the parent
1785        assert_eq!(state.bucket_scroll_offset, 5);
1786        assert!(state.selected_row >= state.bucket_scroll_offset);
1787        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1788    }
1789
1790    #[test]
1791    fn test_scroll_offset_adjusts_after_collapse() {
1792        let mut state = State::new();
1793        state.buckets.items = vec![S3Bucket {
1794            name: "bucket1".to_string(),
1795            region: "us-east-1".to_string(),
1796            creation_date: String::new(),
1797        }];
1798
1799        // Expand bucket with many items
1800        state.expanded_prefixes.insert("bucket1".to_string());
1801        let mut preview = vec![];
1802        for i in 0..20 {
1803            preview.push(S3Object {
1804                key: format!("file{}.txt", i),
1805                is_prefix: false,
1806                size: 100,
1807                last_modified: String::new(),
1808                storage_class: String::new(),
1809            });
1810        }
1811        state.bucket_preview.insert("bucket1".to_string(), preview);
1812
1813        state.bucket_visible_rows.set(10);
1814        state.bucket_scroll_offset = 10; // Viewing rows 10-19
1815        state.selected_row = 15; // Selected row in middle of expanded items
1816
1817        // Collapse the bucket
1818        state.expanded_prefixes.remove("bucket1");
1819
1820        // Selection should now be on the bucket itself (row 0)
1821        state.selected_row = 0;
1822
1823        // Adjust scroll offset
1824        if state.selected_row < state.bucket_scroll_offset {
1825            state.bucket_scroll_offset = state.selected_row;
1826        }
1827
1828        // Scroll offset should adjust to show the bucket
1829        assert_eq!(state.bucket_scroll_offset, 0);
1830        assert!(state.selected_row >= state.bucket_scroll_offset);
1831    }
1832
1833    #[test]
1834    fn test_object_scroll_offset_adjusts_after_jumping_to_parent() {
1835        let mut state = State::new();
1836        state.objects = vec![S3Object {
1837            key: "folder1/".to_string(),
1838            is_prefix: true,
1839            size: 0,
1840            last_modified: String::new(),
1841            storage_class: String::new(),
1842        }];
1843
1844        // Expand folder with many items
1845        state.expanded_prefixes.insert("folder1/".to_string());
1846        let mut preview = vec![];
1847        for i in 0..20 {
1848            preview.push(S3Object {
1849                key: format!("folder1/file{}.txt", i),
1850                is_prefix: false,
1851                size: 100,
1852                last_modified: String::new(),
1853                storage_class: String::new(),
1854            });
1855        }
1856        state.prefix_preview.insert("folder1/".to_string(), preview);
1857
1858        state.object_visible_rows.set(10);
1859        state.object_scroll_offset = 10; // Viewing rows 10-19
1860        state.selected_object = 15; // Selected row in middle of expanded items
1861
1862        // Jump to parent folder (row 0)
1863        state.selected_object = 0;
1864
1865        // Adjust scroll offset
1866        let visible_rows = state.object_visible_rows.get();
1867        if state.selected_object < state.object_scroll_offset {
1868            state.object_scroll_offset = state.selected_object;
1869        } else if state.selected_object >= state.object_scroll_offset + visible_rows {
1870            state.object_scroll_offset = state.selected_object.saturating_sub(visible_rows - 1);
1871        }
1872
1873        // Scroll offset should adjust to show the parent
1874        assert_eq!(state.object_scroll_offset, 0);
1875        assert!(state.selected_object >= state.object_scroll_offset);
1876        assert!(state.selected_object < state.object_scroll_offset + visible_rows);
1877    }
1878
1879    #[test]
1880    fn test_selection_below_viewport_becomes_visible() {
1881        let mut state = State::new();
1882        state.bucket_visible_rows.set(10);
1883        state.bucket_scroll_offset = 0; // Viewing rows 0-9
1884        state.selected_row = 0;
1885
1886        // Jump to row 20 (way below viewport)
1887        state.selected_row = 20;
1888
1889        // Adjust scroll offset
1890        let visible_rows = state.bucket_visible_rows.get();
1891        if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1892            state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1893        }
1894
1895        // Scroll offset should adjust to show the selection
1896        assert_eq!(state.bucket_scroll_offset, 11); // 20 - 10 + 1
1897        assert!(state.selected_row >= state.bucket_scroll_offset);
1898        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1899    }
1900
1901    #[test]
1902    fn test_scroll_keeps_selection_visible_when_navigating_down() {
1903        let mut state = State::new();
1904        state.buckets.items = vec![];
1905        for i in 0..50 {
1906            state.buckets.items.push(S3Bucket {
1907                name: format!("bucket{}", i),
1908                region: "us-east-1".to_string(),
1909                creation_date: String::new(),
1910            });
1911        }
1912
1913        state.bucket_visible_rows.set(10);
1914        state.bucket_scroll_offset = 0;
1915        state.selected_row = 0;
1916
1917        // Navigate down 25 times
1918        for _ in 0..25 {
1919            let total_rows = state.buckets.items.len();
1920            state.selected_row = (state.selected_row + 1).min(total_rows - 1);
1921
1922            // Adjust scroll offset (same logic as in app.rs)
1923            let visible_rows = state.bucket_visible_rows.get();
1924            if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1925                state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1926            }
1927        }
1928
1929        // Selection should be at row 25
1930        assert_eq!(state.selected_row, 25);
1931        // Scroll offset should be 16 (25 - 10 + 1)
1932        assert_eq!(state.bucket_scroll_offset, 16);
1933        // Selection should be visible
1934        assert!(state.selected_row >= state.bucket_scroll_offset);
1935        assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1936    }
1937
1938    #[test]
1939    fn test_ctrl_d_adjusts_scroll_offset() {
1940        let mut state = State::new();
1941        state.buckets.items = vec![];
1942        for i in 0..50 {
1943            state.buckets.items.push(S3Bucket {
1944                name: format!("bucket{}", i),
1945                region: "us-east-1".to_string(),
1946                creation_date: String::new(),
1947            });
1948        }
1949
1950        state.bucket_visible_rows.set(10);
1951        state.bucket_scroll_offset = 0;
1952        state.selected_row = 5;
1953
1954        // Simulate Ctrl+D (jump down 10)
1955        let total_rows = state.buckets.items.len();
1956        state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
1957
1958        // Adjust scroll offset
1959        let visible_rows = state.bucket_visible_rows.get();
1960        if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1961            state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1962        }
1963
1964        // Selection should be at row 15
1965        assert_eq!(state.selected_row, 15);
1966        // Scroll offset should be 6 (15 - 10 + 1)
1967        assert_eq!(state.bucket_scroll_offset, 6);
1968        // Selection should be visible
1969        assert!(state.selected_row >= state.bucket_scroll_offset);
1970        assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1971    }
1972
1973    #[test]
1974    fn test_ctrl_u_adjusts_scroll_offset() {
1975        let mut state = State::new();
1976        state.buckets.items = vec![];
1977        for i in 0..50 {
1978            state.buckets.items.push(S3Bucket {
1979                name: format!("bucket{}", i),
1980                region: "us-east-1".to_string(),
1981                creation_date: String::new(),
1982            });
1983        }
1984
1985        state.bucket_visible_rows.set(10);
1986        state.bucket_scroll_offset = 10;
1987        state.selected_row = 15;
1988
1989        // Simulate Ctrl+U (jump up 10)
1990        state.selected_row = state.selected_row.saturating_sub(10);
1991
1992        // Adjust scroll offset
1993        if state.selected_row < state.bucket_scroll_offset {
1994            state.bucket_scroll_offset = state.selected_row;
1995        }
1996
1997        // Selection should be at row 5
1998        assert_eq!(state.selected_row, 5);
1999        // Scroll offset should be 5
2000        assert_eq!(state.bucket_scroll_offset, 5);
2001        // Selection should be visible
2002        assert!(state.selected_row >= state.bucket_scroll_offset);
2003    }
2004
2005    #[test]
2006    fn test_ctrl_d_clamps_to_max_rows() {
2007        let mut state = State::new();
2008        state.buckets.items = vec![];
2009        for i in 0..20 {
2010            state.buckets.items.push(S3Bucket {
2011                name: format!("bucket{}", i),
2012                region: "us-east-1".to_string(),
2013                creation_date: String::new(),
2014            });
2015        }
2016
2017        state.bucket_visible_rows.set(10);
2018        state.bucket_scroll_offset = 5;
2019        state.selected_row = 15;
2020
2021        // Simulate Ctrl+D (jump down 10) - should clamp to max
2022        let total_rows = state.buckets.items.len();
2023        state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
2024
2025        // Selection should be clamped to 19 (last row)
2026        assert_eq!(state.selected_row, 19);
2027    }
2028
2029    #[test]
2030    fn test_rounded_block_with_custom_border_style() {
2031        use ratatui::prelude::Rect;
2032        let block = rounded_block()
2033            .title(format_title("Properties"))
2034            .border_style(active_border());
2035        let area = Rect::new(0, 0, 70, 12);
2036        let inner = block.inner(area);
2037        assert_eq!(inner.width, 68);
2038        assert_eq!(inner.height, 10);
2039    }
2040
2041    #[test]
2042    fn test_column_width_accounts_for_header_text() {
2043        // Test that column widths are at least as wide as header text
2044        let header_name = "Name";
2045        let header_region = "Region";
2046        let header_date = "Creation date";
2047
2048        // First column has no separator
2049        assert!(header_name.len() >= 4);
2050
2051        // Other columns need space for "⋮ " separator (2 chars)
2052        let region_with_sep = header_region.len() + 2;
2053        let date_with_sep = header_date.len() + 2;
2054
2055        assert!(region_with_sep >= header_region.len());
2056        assert!(date_with_sep >= header_date.len());
2057    }
2058
2059    #[test]
2060    fn test_title_formatting_no_double_dash() {
2061        // Test that title formatting is correct
2062        let title = format!("{} ({})", "Directory buckets", 0);
2063        let formatted = format_title(&title);
2064
2065        // Should be "─ Directory buckets (0) ─"
2066        assert_eq!(formatted, "─ Directory buckets (0) ─");
2067    }
2068
2069    #[test]
2070    fn test_collapse_moves_to_parent() {
2071        // Test that collapsing an expanded node moves selection to parent
2072        let mut state = State::new();
2073        state.buckets.items = vec![S3Bucket {
2074            name: "bucket1".to_string(),
2075            region: "us-east-1".to_string(),
2076            creation_date: String::new(),
2077        }];
2078
2079        // Expand bucket with a folder
2080        state.expanded_prefixes.insert("bucket1".to_string());
2081        state.bucket_preview.insert(
2082            "bucket1".to_string(),
2083            vec![S3Object {
2084                key: "folder1/".to_string(),
2085                is_prefix: true,
2086                size: 0,
2087                last_modified: String::new(),
2088                storage_class: String::new(),
2089            }],
2090        );
2091
2092        // Expand the folder
2093        state.expanded_prefixes.insert("folder1/".to_string());
2094        state.prefix_preview.insert(
2095            "folder1/".to_string(),
2096            vec![S3Object {
2097                key: "folder1/file.txt".to_string(),
2098                is_prefix: false,
2099                size: 100,
2100                last_modified: String::new(),
2101                storage_class: String::new(),
2102            }],
2103        );
2104
2105        // Select the expanded folder (row 1)
2106        state.selected_row = 1;
2107
2108        // Verify folder is expanded
2109        assert!(state.expanded_prefixes.contains("folder1/"));
2110
2111        // After collapse, selection should move to parent (bucket at row 0)
2112        // This is tested in app.rs Left key handling
2113    }
2114
2115    #[test]
2116    fn test_hierarchy_collapse_sequence() {
2117        // Test that pressing left multiple times collapses hierarchy level by level
2118        let mut state = State::new();
2119        state.buckets.items = vec![S3Bucket {
2120            name: "bucket1".to_string(),
2121            region: "us-east-1".to_string(),
2122            creation_date: String::new(),
2123        }];
2124
2125        // Create 3-level hierarchy
2126        state.expanded_prefixes.insert("bucket1".to_string());
2127        state.bucket_preview.insert(
2128            "bucket1".to_string(),
2129            vec![S3Object {
2130                key: "level1/".to_string(),
2131                is_prefix: true,
2132                size: 0,
2133                last_modified: String::new(),
2134                storage_class: String::new(),
2135            }],
2136        );
2137
2138        state.expanded_prefixes.insert("level1/".to_string());
2139        state.prefix_preview.insert(
2140            "level1/".to_string(),
2141            vec![S3Object {
2142                key: "level1/level2/".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/level2/".to_string());
2151        state.prefix_preview.insert(
2152            "level1/level2/".to_string(),
2153            vec![S3Object {
2154                key: "level1/level2/file.txt".to_string(),
2155                is_prefix: false,
2156                size: 100,
2157                last_modified: String::new(),
2158                storage_class: String::new(),
2159            }],
2160        );
2161
2162        // All 3 levels should be expanded
2163        assert_eq!(state.expanded_prefixes.len(), 3);
2164
2165        // After 3 left presses, all should be collapsed
2166        // This is tested in app.rs Left key handling
2167    }
2168
2169    #[test]
2170    fn test_objects_collapse_jumps_to_parent() {
2171        // Test that collapsing an expanded prefix in objects view jumps to parent
2172        let mut state = State::new();
2173        state.current_bucket = Some("test-bucket".to_string());
2174
2175        // Create hierarchy: folder1/ -> folder2/ -> file.txt
2176        state.objects = vec![S3Object {
2177            key: "folder1/".to_string(),
2178            is_prefix: true,
2179            size: 0,
2180            last_modified: String::new(),
2181            storage_class: String::new(),
2182        }];
2183
2184        state.expanded_prefixes.insert("folder1/".to_string());
2185        state.prefix_preview.insert(
2186            "folder1/".to_string(),
2187            vec![S3Object {
2188                key: "folder1/folder2/".to_string(),
2189                is_prefix: true,
2190                size: 0,
2191                last_modified: String::new(),
2192                storage_class: String::new(),
2193            }],
2194        );
2195
2196        state
2197            .expanded_prefixes
2198            .insert("folder1/folder2/".to_string());
2199        state.prefix_preview.insert(
2200            "folder1/folder2/".to_string(),
2201            vec![S3Object {
2202                key: "folder1/folder2/file.txt".to_string(),
2203                is_prefix: false,
2204                size: 100,
2205                last_modified: String::new(),
2206                storage_class: String::new(),
2207            }],
2208        );
2209
2210        // Select folder2 (visual index 1: folder1, then folder2)
2211        state.selected_object = 1;
2212
2213        // Verify folder2 is expanded
2214        assert!(state.expanded_prefixes.contains("folder1/folder2/"));
2215
2216        // After collapse, folder2 should be collapsed and selection should jump to parent (folder1)
2217        // This behavior is tested in app.rs prev_pane function
2218        // Expected: expanded_prefixes.remove("folder1/folder2/") and selected_object = 0
2219    }
2220
2221    #[test]
2222    fn test_bucket_shows_collapsed_cursor_while_loading_no_preview() {
2223        // Regression: bucket must show ▶ (collapsed) cursor while loading,
2224        // and ▼ (expanded) cursor only when preview is actually loaded.
2225        let mut state = State::new();
2226        state.buckets.items = vec![S3Bucket {
2227            name: "my-bucket".to_string(),
2228            region: String::new(),
2229            creation_date: String::new(),
2230        }];
2231
2232        // Bucket in expanded_prefixes (loading started) but no preview yet
2233        state.expanded_prefixes.insert("my-bucket".to_string());
2234
2235        // Correct logic: is_expanded requires preview to be present
2236        let preview_loaded = state.bucket_preview.contains_key("my-bucket");
2237        let is_expanded = state.expanded_prefixes.contains("my-bucket") && preview_loaded;
2238        assert!(
2239            !is_expanded,
2240            "Cursor must be ▶ (collapsed) while preview is still loading"
2241        );
2242
2243        // After preview loads, cursor becomes ▼
2244        state.bucket_preview.insert("my-bucket".to_string(), vec![]);
2245        let preview_loaded = state.bucket_preview.contains_key("my-bucket");
2246        let is_expanded = state.expanded_prefixes.contains("my-bucket") && preview_loaded;
2247        assert!(
2248            is_expanded,
2249            "Cursor must be ▼ (expanded) once preview is loaded"
2250        );
2251    }
2252}