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
248fn 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), Constraint::Length(3), Constraint::Min(0), ],
258 area,
259 );
260
261 let visible_rows = chunks[2].height.saturating_sub(3) as usize;
263 app.s3_state.bucket_visible_rows.set(visible_rows);
264
265 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 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 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 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); max_date_width = max_date_width.max(27); 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 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 let formatted_date = if bucket.creation_date.contains('T') {
409 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 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 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 } else if let Some(preview) = app.s3_state.bucket_preview.get(&bucket.name) {
526 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 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 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 let total_rows = app.s3_state.calculate_total_bucket_rows();
660 let visible_rows = chunks[2].height.saturating_sub(3) as usize; 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), Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ],
685 area,
686 )
687 } else {
688 crate::ui::vertical(
689 [
690 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ],
694 area,
695 )
696 };
697
698 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 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 let available_tabs = if app.s3_state.prefix_stack.is_empty() {
727 ObjectTab::all()
729 } else {
730 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 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 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 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 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 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 })
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 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 let datetime = format_iso_timestamp(&obj.last_modified);
884
885 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 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 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 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 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 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 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 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 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 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 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 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 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 lines.push(crate::ui::section_header("Object Lock", inner.width));
1216 lines.push(Line::from("Disabled"));
1217 lines.push(Line::from(""));
1218
1219 lines.push(crate::ui::section_header("Requester pays", inner.width));
1221 lines.push(Line::from("Disabled"));
1222 lines.push(Line::from(""));
1223
1224 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 let content_height = 40; 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
1253pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; 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); 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; 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 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 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; state.selected_row = 15; state.selected_row = 5;
1655
1656 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 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 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; state.selected_row = 15; state.expanded_prefixes.remove("bucket1");
1699
1700 state.selected_row = 0;
1702
1703 if state.selected_row < state.bucket_scroll_offset {
1705 state.bucket_scroll_offset = state.selected_row;
1706 }
1707
1708 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 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; state.selected_object = 15; state.selected_object = 0;
1744
1745 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 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; state.selected_row = 0;
1765
1766 state.selected_row = 20;
1768
1769 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 assert_eq!(state.bucket_scroll_offset, 11); 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 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 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 assert_eq!(state.selected_row, 25);
1811 assert_eq!(state.bucket_scroll_offset, 16);
1813 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 let total_rows = state.buckets.items.len();
1836 state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
1837
1838 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 assert_eq!(state.selected_row, 15);
1846 assert_eq!(state.bucket_scroll_offset, 6);
1848 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 state.selected_row = state.selected_row.saturating_sub(10);
1871
1872 if state.selected_row < state.bucket_scroll_offset {
1874 state.bucket_scroll_offset = state.selected_row;
1875 }
1876
1877 assert_eq!(state.selected_row, 5);
1879 assert_eq!(state.bucket_scroll_offset, 5);
1881 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 let total_rows = state.buckets.items.len();
1903 state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
1904
1905 assert_eq!(state.selected_row, 19);
1907 }
1908}