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