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