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