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
259fn 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), Constraint::Length(3), Constraint::Min(0), ],
269 area,
270 );
271
272 let visible_rows = chunks[2].height.saturating_sub(3) as usize;
274 app.s3_state.bucket_visible_rows.set(visible_rows);
275
276 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 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 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 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); max_date_width = max_date_width.max(27); 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 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 let formatted_date = if bucket.creation_date.contains('T') {
403 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 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 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 } else if let Some(preview) = app.s3_state.bucket_preview.get(&bucket.name) {
522 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 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 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 let total_rows = app.s3_state.calculate_total_bucket_rows();
659 let visible_rows = chunks[2].height.saturating_sub(3) as usize; 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), Constraint::Length(3), Constraint::Min(0), ],
683 area,
684 )
685 } else {
686 crate::ui::vertical(
687 [
688 Constraint::Length(1), Constraint::Min(0), ],
691 area,
692 )
693 };
694
695 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 let available_tabs = if app.s3_state.prefix_stack.is_empty() {
702 ObjectTab::all()
704 } else {
705 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 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 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 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 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 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 })
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 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 let datetime = format_iso_timestamp(&obj.last_modified);
851
852 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 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 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 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 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 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 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 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 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 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 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 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 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 lines.push(crate::ui::section_header("Object Lock", inner.width));
1185 lines.push(Line::from("Disabled"));
1186 lines.push(Line::from(""));
1187
1188 lines.push(crate::ui::section_header("Requester pays", inner.width));
1190 lines.push(Line::from("Disabled"));
1191 lines.push(Line::from(""));
1192
1193 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 let content_height = 40; 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
1222pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; 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); 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; 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 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 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; state.selected_row = 15; state.selected_row = 5;
1624
1625 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 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 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; state.selected_row = 15; state.expanded_prefixes.remove("bucket1");
1668
1669 state.selected_row = 0;
1671
1672 if state.selected_row < state.bucket_scroll_offset {
1674 state.bucket_scroll_offset = state.selected_row;
1675 }
1676
1677 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 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; state.selected_object = 15; state.selected_object = 0;
1713
1714 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 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; state.selected_row = 0;
1734
1735 state.selected_row = 20;
1737
1738 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 assert_eq!(state.bucket_scroll_offset, 11); 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 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 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 assert_eq!(state.selected_row, 25);
1780 assert_eq!(state.bucket_scroll_offset, 16);
1782 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 let total_rows = state.buckets.items.len();
1805 state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
1806
1807 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 assert_eq!(state.selected_row, 15);
1815 assert_eq!(state.bucket_scroll_offset, 6);
1817 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 state.selected_row = state.selected_row.saturating_sub(10);
1840
1841 if state.selected_row < state.bucket_scroll_offset {
1843 state.bucket_scroll_offset = state.selected_row;
1844 }
1845
1846 assert_eq!(state.selected_row, 5);
1848 assert_eq!(state.bucket_scroll_offset, 5);
1850 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 let total_rows = state.buckets.items.len();
1872 state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
1873
1874 assert_eq!(state.selected_row, 19);
1876 }
1877}