rusticity_term/ui/
s3.rs

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