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