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 columns = ["Name", "Type", "Last modified", "Size", "Storage class"];
969 let header_cells: Vec<Cell> = columns
970 .iter()
971 .enumerate()
972 .map(|(i, name)| {
973 Cell::from(format_header_cell(name, i))
974 .style(Style::default().add_modifier(Modifier::BOLD))
975 })
976 .collect();
977 let header = Row::new(header_cells)
978 .style(Style::default().bg(Color::White).fg(Color::Black))
979 .height(1)
980 .bottom_margin(0);
981
982 let max_name_width = filtered_objects
984 .iter()
985 .map(|(_, obj)| {
986 let name = obj.key.trim_start_matches(
987 &app.s3_state
988 .prefix_stack
989 .last()
990 .cloned()
991 .unwrap_or_default(),
992 );
993 name.len() + 4 })
995 .max()
996 .unwrap_or(30)
997 .max(30) as u16;
998
999 let rows: Vec<Row> = filtered_objects
1000 .iter()
1001 .flat_map(|(idx, obj)| {
1002 let icon = if obj.is_prefix { "📁" } else { "📄" };
1003
1004 let expand_indicator = if obj.is_prefix {
1006 if app.s3_state.expanded_prefixes.contains(&obj.key) {
1007 format!("{} ", CURSOR_EXPANDED)
1008 } else {
1009 format!("{} ", CURSOR_COLLAPSED)
1010 }
1011 } else {
1012 String::new()
1013 };
1014
1015 let name = obj.key.trim_start_matches(
1016 &app.s3_state
1017 .prefix_stack
1018 .last()
1019 .cloned()
1020 .unwrap_or_default(),
1021 );
1022 let display_name = format!("{}{} {}", expand_indicator, icon, name);
1023 let obj_type = if obj.is_prefix { "Folder" } else { "File" };
1024 let size = if obj.is_prefix {
1025 String::new()
1026 } else {
1027 format_bytes(obj.size)
1028 };
1029
1030 let datetime = format_iso_timestamp(&obj.last_modified);
1032
1033 let storage = if obj.storage_class.is_empty() {
1035 String::new()
1036 } else {
1037 obj.storage_class
1038 .chars()
1039 .next()
1040 .unwrap()
1041 .to_uppercase()
1042 .to_string()
1043 + &obj.storage_class[1..].to_lowercase()
1044 };
1045
1046 let mut row_idx = *idx;
1048 for i in 0..*idx {
1049 if let Some(prev_obj) = app.s3_state.objects.get(i) {
1050 if prev_obj.is_prefix && app.s3_state.expanded_prefixes.contains(&prev_obj.key)
1051 {
1052 if let Some(preview) = app.s3_state.prefix_preview.get(&prev_obj.key) {
1053 row_idx += preview.len();
1054 }
1055 }
1056 }
1057 }
1058
1059 let style = if row_idx == app.s3_state.selected_object {
1060 Style::default().bg(Color::DarkGray)
1061 } else {
1062 Style::default()
1063 };
1064
1065 let mut result = vec![Row::new(vec![
1066 Cell::from(display_name),
1067 Cell::from(format!("⋮ {}", obj_type)),
1068 Cell::from(format!("⋮ {}", datetime)),
1069 Cell::from(format!("⋮ {}", size)),
1070 Cell::from(format!("⋮ {}", storage)),
1071 ])
1072 .style(style)];
1073
1074 let mut child_row_idx = row_idx + 1;
1075
1076 if obj.is_prefix && app.s3_state.expanded_prefixes.contains(&obj.key) {
1077 if let Some(preview) = app.s3_state.prefix_preview.get(&obj.key) {
1078 fn render_nested_objects<'a>(
1080 objects: &'a [S3Object],
1081 app: &'a App,
1082 child_row_idx: &mut usize,
1083 result: &mut Vec<Row<'a>>,
1084 parent_key: &str,
1085 is_last: &[bool],
1086 ) {
1087 for (child_idx, preview_obj) in objects.iter().enumerate() {
1088 let is_last_child = child_idx == objects.len() - 1;
1089 let obj_is_expanded =
1090 app.s3_state.expanded_prefixes.contains(&preview_obj.key);
1091
1092 let mut prefix = String::new();
1094 for &last in is_last.iter() {
1095 prefix.push_str(if last { " " } else { "│ " });
1096 }
1097
1098 let tree_char = if is_last_child { "╰─" } else { "├─" };
1099 let child_expand = if preview_obj.is_prefix {
1100 if obj_is_expanded {
1101 CURSOR_EXPANDED
1102 } else {
1103 CURSOR_COLLAPSED
1104 }
1105 } else {
1106 ""
1107 };
1108 let child_icon = if preview_obj.is_prefix {
1109 "📁"
1110 } else {
1111 "📄"
1112 };
1113 let child_name = preview_obj
1114 .key
1115 .strip_prefix(parent_key)
1116 .unwrap_or(&preview_obj.key);
1117
1118 let child_type = if preview_obj.is_prefix {
1119 "Folder"
1120 } else {
1121 "File"
1122 };
1123 let child_size = if preview_obj.is_prefix {
1124 String::new()
1125 } else {
1126 format_bytes(preview_obj.size)
1127 };
1128 let child_datetime = format_iso_timestamp(&preview_obj.last_modified);
1129 let child_storage = if preview_obj.storage_class.is_empty() {
1130 String::new()
1131 } else {
1132 preview_obj
1133 .storage_class
1134 .chars()
1135 .next()
1136 .unwrap()
1137 .to_uppercase()
1138 .to_string()
1139 + &preview_obj.storage_class[1..].to_lowercase()
1140 };
1141
1142 let child_style = if *child_row_idx == app.s3_state.selected_object {
1143 Style::default().bg(Color::DarkGray)
1144 } else {
1145 Style::default()
1146 };
1147
1148 result.push(
1149 Row::new(vec![
1150 Cell::from(format!(
1151 "{}{}{} {} {}",
1152 prefix, tree_char, child_expand, child_icon, child_name
1153 )),
1154 Cell::from(format!("⋮ {}", child_type)),
1155 Cell::from(format!("⋮ {}", child_datetime)),
1156 Cell::from(format!("⋮ {}", child_size)),
1157 Cell::from(format!("⋮ {}", child_storage)),
1158 ])
1159 .style(child_style),
1160 );
1161 *child_row_idx += 1;
1162
1163 if preview_obj.is_prefix && obj_is_expanded {
1165 if let Some(nested_preview) =
1166 app.s3_state.prefix_preview.get(&preview_obj.key)
1167 {
1168 let mut new_is_last = is_last.to_vec();
1169 new_is_last.push(is_last_child);
1170 render_nested_objects(
1171 nested_preview,
1172 app,
1173 child_row_idx,
1174 result,
1175 &preview_obj.key,
1176 &new_is_last,
1177 );
1178 }
1179 }
1180 }
1181 }
1182
1183 render_nested_objects(
1184 preview,
1185 app,
1186 &mut child_row_idx,
1187 &mut result,
1188 &obj.key,
1189 &[],
1190 );
1191 }
1192 }
1193
1194 result
1195 })
1196 .skip(app.s3_state.object_scroll_offset)
1197 .take(app.s3_state.object_visible_rows.get())
1198 .collect();
1199
1200 let table = Table::new(
1201 rows,
1202 vec![
1203 Constraint::Length(max_name_width),
1204 Constraint::Length(10),
1205 Constraint::Length(UTC_TIMESTAMP_WIDTH),
1206 Constraint::Length(12),
1207 Constraint::Length(15),
1208 ],
1209 )
1210 .header(header)
1211 .column_spacing(1)
1212 .block(rounded_block().title(title).border_style(active_border()));
1213
1214 frame.render_widget(table, area);
1215
1216 let total_rows = app.s3_state.calculate_total_object_rows();
1218 let visible_rows = area.height.saturating_sub(3) as usize;
1219 if total_rows > visible_rows {
1220 render_scrollbar(
1221 frame,
1222 area.inner(Margin {
1223 vertical: 1,
1224 horizontal: 0,
1225 }),
1226 total_rows,
1227 app.s3_state.selected_object,
1228 );
1229 }
1230}
1231
1232fn render_bucket_properties(frame: &mut Frame, app: &App, area: Rect) {
1233 let bucket_name = app.s3_state.current_bucket.as_ref().unwrap();
1234 let bucket = app
1235 .s3_state
1236 .buckets
1237 .items
1238 .iter()
1239 .find(|b| &b.name == bucket_name);
1240
1241 let mut lines = vec![];
1242
1243 let block = rounded_block()
1244 .title(format_title("Properties"))
1245 .border_style(active_border());
1246 let inner = block.inner(area);
1247
1248 lines.push(section_header("Bucket overview", inner.width));
1250 if let Some(b) = bucket {
1251 let region = if b.region.is_empty() {
1252 "us-east-1"
1253 } else {
1254 &b.region
1255 };
1256 let formatted_date = if b.creation_date.contains('T') {
1257 let parts: Vec<&str> = b.creation_date.split('T').collect();
1258 if parts.len() == 2 {
1259 format!(
1260 "{} {} (UTC)",
1261 parts[0],
1262 parts[1]
1263 .trim_end_matches('Z')
1264 .split('.')
1265 .next()
1266 .unwrap_or(parts[1])
1267 )
1268 } else {
1269 b.creation_date.clone()
1270 }
1271 } else {
1272 b.creation_date.clone()
1273 };
1274 lines.push(Line::from(vec![
1275 Span::styled(
1276 "AWS Region: ",
1277 Style::default().add_modifier(Modifier::BOLD),
1278 ),
1279 Span::raw(region),
1280 ]));
1281 lines.push(Line::from(vec![
1282 Span::styled(
1283 "Amazon Resource Name (ARN): ",
1284 Style::default().add_modifier(Modifier::BOLD),
1285 ),
1286 Span::raw(format!("arn:aws:s3:::{}", bucket_name)),
1287 ]));
1288 lines.push(Line::from(vec![
1289 Span::styled(
1290 "Creation date: ",
1291 Style::default().add_modifier(Modifier::BOLD),
1292 ),
1293 Span::raw(formatted_date),
1294 ]));
1295 }
1296 lines.push(Line::from(""));
1297
1298 lines.push(section_header("Tags (0)", inner.width));
1300 lines.push(Line::from("No tags associated with this resource."));
1301 lines.push(Line::from(""));
1302
1303 lines.push(section_header("Default encryption", inner.width));
1305 lines.push(Line::from(vec![
1306 Span::styled(
1307 "Encryption type: ",
1308 Style::default().add_modifier(Modifier::BOLD),
1309 ),
1310 Span::raw("Server-side encryption with Amazon S3 managed keys (SSE-S3)"),
1311 ]));
1312 lines.push(Line::from(vec![
1313 Span::styled(
1314 "Bucket Key: ",
1315 Style::default().add_modifier(Modifier::BOLD),
1316 ),
1317 Span::raw("Disabled"),
1318 ]));
1319 lines.push(Line::from(""));
1320
1321 lines.push(section_header("Server access logging", inner.width));
1323 lines.push(Line::from("Disabled"));
1324 lines.push(Line::from(""));
1325
1326 lines.push(section_header("AWS CloudTrail data events", inner.width));
1328 lines.push(Line::from("Configure in CloudTrail console"));
1329 lines.push(Line::from(""));
1330
1331 lines.push(section_header("Amazon EventBridge", inner.width));
1333 lines.push(Line::from(vec![
1334 Span::styled(
1335 "Send notifications to Amazon EventBridge: ",
1336 Style::default().add_modifier(Modifier::BOLD),
1337 ),
1338 Span::raw("Off"),
1339 ]));
1340 lines.push(Line::from(""));
1341
1342 lines.push(section_header("Transfer acceleration", inner.width));
1344 lines.push(Line::from("Disabled"));
1345 lines.push(Line::from(""));
1346
1347 lines.push(section_header("Object Lock", inner.width));
1349 lines.push(Line::from("Disabled"));
1350 lines.push(Line::from(""));
1351
1352 lines.push(section_header("Requester pays", inner.width));
1354 lines.push(Line::from("Disabled"));
1355 lines.push(Line::from(""));
1356
1357 lines.push(section_header("Static website hosting", inner.width));
1359 lines.push(Line::from("Disabled"));
1360
1361 let paragraph = Paragraph::new(lines)
1362 .block(block)
1363 .wrap(Wrap { trim: false })
1364 .scroll((app.s3_state.properties_scroll, 0));
1365
1366 frame.render_widget(paragraph, area);
1367
1368 let content_height = 40; if content_height > area.height.saturating_sub(2) {
1371 render_scrollbar(
1372 frame,
1373 area.inner(Margin {
1374 vertical: 1,
1375 horizontal: 0,
1376 }),
1377 content_height as usize,
1378 app.s3_state.properties_scroll as usize,
1379 );
1380 }
1381}
1382
1383pub async fn load_s3_buckets(app: &mut App) -> anyhow::Result<()> {
1385 let buckets = app.s3_client.list_buckets().await?;
1386 app.s3_state.buckets.items = buckets
1387 .into_iter()
1388 .map(|(name, region, date)| AppS3Bucket {
1389 name,
1390 region,
1391 creation_date: date,
1392 })
1393 .collect();
1394 Ok(())
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399 use super::*;
1400
1401 #[test]
1402 fn test_calculate_total_bucket_rows_no_expansion() {
1403 let mut state = State::new();
1404 state.buckets.items = vec![
1405 S3Bucket {
1406 name: "bucket1".to_string(),
1407 region: "us-east-1".to_string(),
1408 creation_date: String::new(),
1409 },
1410 S3Bucket {
1411 name: "bucket2".to_string(),
1412 region: "us-west-2".to_string(),
1413 creation_date: String::new(),
1414 },
1415 ];
1416
1417 assert_eq!(state.calculate_total_bucket_rows(), 2);
1418 }
1419
1420 #[test]
1421 fn test_calculate_total_bucket_rows_with_expansion() {
1422 let mut state = State::new();
1423 state.buckets.items = vec![S3Bucket {
1424 name: "bucket1".to_string(),
1425 region: "us-east-1".to_string(),
1426 creation_date: String::new(),
1427 }];
1428
1429 state.expanded_prefixes.insert("bucket1".to_string());
1431 state.bucket_preview.insert(
1432 "bucket1".to_string(),
1433 vec![
1434 S3Object {
1435 key: "file1.txt".to_string(),
1436 is_prefix: false,
1437 size: 100,
1438 last_modified: String::new(),
1439 storage_class: String::new(),
1440 },
1441 S3Object {
1442 key: "folder1/".to_string(),
1443 is_prefix: true,
1444 size: 0,
1445 last_modified: String::new(),
1446 storage_class: String::new(),
1447 },
1448 ],
1449 );
1450
1451 assert_eq!(state.calculate_total_bucket_rows(), 3);
1453 }
1454
1455 #[test]
1456 fn test_calculate_total_bucket_rows_nested_expansion() {
1457 let mut state = State::new();
1458 state.buckets.items = vec![S3Bucket {
1459 name: "bucket1".to_string(),
1460 region: "us-east-1".to_string(),
1461 creation_date: String::new(),
1462 }];
1463
1464 state.expanded_prefixes.insert("bucket1".to_string());
1466 state.bucket_preview.insert(
1467 "bucket1".to_string(),
1468 vec![S3Object {
1469 key: "folder1/".to_string(),
1470 is_prefix: true,
1471 size: 0,
1472 last_modified: String::new(),
1473 storage_class: String::new(),
1474 }],
1475 );
1476
1477 state.expanded_prefixes.insert("folder1/".to_string());
1479 state.prefix_preview.insert(
1480 "folder1/".to_string(),
1481 vec![
1482 S3Object {
1483 key: "folder1/file1.txt".to_string(),
1484 is_prefix: false,
1485 size: 100,
1486 last_modified: String::new(),
1487 storage_class: String::new(),
1488 },
1489 S3Object {
1490 key: "folder1/file2.txt".to_string(),
1491 is_prefix: false,
1492 size: 200,
1493 last_modified: String::new(),
1494 storage_class: String::new(),
1495 },
1496 ],
1497 );
1498
1499 assert_eq!(state.calculate_total_bucket_rows(), 4);
1501 }
1502
1503 #[test]
1504 fn test_calculate_total_bucket_rows_deeply_nested() {
1505 let mut state = State::new();
1506 state.buckets.items = vec![S3Bucket {
1507 name: "bucket1".to_string(),
1508 region: "us-east-1".to_string(),
1509 creation_date: String::new(),
1510 }];
1511
1512 state.expanded_prefixes.insert("bucket1".to_string());
1514 state.bucket_preview.insert(
1515 "bucket1".to_string(),
1516 vec![S3Object {
1517 key: "folder1/".to_string(),
1518 is_prefix: true,
1519 size: 0,
1520 last_modified: String::new(),
1521 storage_class: String::new(),
1522 }],
1523 );
1524
1525 state.expanded_prefixes.insert("folder1/".to_string());
1527 state.prefix_preview.insert(
1528 "folder1/".to_string(),
1529 vec![S3Object {
1530 key: "folder1/folder2/".to_string(),
1531 is_prefix: true,
1532 size: 0,
1533 last_modified: String::new(),
1534 storage_class: String::new(),
1535 }],
1536 );
1537
1538 state
1540 .expanded_prefixes
1541 .insert("folder1/folder2/".to_string());
1542 state.prefix_preview.insert(
1543 "folder1/folder2/".to_string(),
1544 vec![
1545 S3Object {
1546 key: "folder1/folder2/file1.txt".to_string(),
1547 is_prefix: false,
1548 size: 100,
1549 last_modified: String::new(),
1550 storage_class: String::new(),
1551 },
1552 S3Object {
1553 key: "folder1/folder2/file2.txt".to_string(),
1554 is_prefix: false,
1555 size: 200,
1556 last_modified: String::new(),
1557 storage_class: String::new(),
1558 },
1559 S3Object {
1560 key: "folder1/folder2/file3.txt".to_string(),
1561 is_prefix: false,
1562 size: 300,
1563 last_modified: String::new(),
1564 storage_class: String::new(),
1565 },
1566 ],
1567 );
1568
1569 assert_eq!(state.calculate_total_bucket_rows(), 6);
1571 }
1572
1573 #[test]
1574 fn test_calculate_total_object_rows_no_expansion() {
1575 let mut state = State::new();
1576 state.objects = vec![
1577 S3Object {
1578 key: "folder1/".to_string(),
1579 is_prefix: true,
1580 size: 0,
1581 last_modified: String::new(),
1582 storage_class: String::new(),
1583 },
1584 S3Object {
1585 key: "folder2/".to_string(),
1586 is_prefix: true,
1587 size: 0,
1588 last_modified: String::new(),
1589 storage_class: String::new(),
1590 },
1591 S3Object {
1592 key: "file.txt".to_string(),
1593 is_prefix: false,
1594 size: 100,
1595 last_modified: String::new(),
1596 storage_class: String::new(),
1597 },
1598 ];
1599
1600 assert_eq!(state.calculate_total_object_rows(), 3);
1601 }
1602
1603 #[test]
1604 fn test_calculate_total_object_rows_with_expansion() {
1605 let mut state = State::new();
1606 state.objects = vec![
1607 S3Object {
1608 key: "folder1/".to_string(),
1609 is_prefix: true,
1610 size: 0,
1611 last_modified: String::new(),
1612 storage_class: String::new(),
1613 },
1614 S3Object {
1615 key: "file.txt".to_string(),
1616 is_prefix: false,
1617 size: 100,
1618 last_modified: String::new(),
1619 storage_class: String::new(),
1620 },
1621 ];
1622
1623 state.expanded_prefixes.insert("folder1/".to_string());
1625 state.prefix_preview.insert(
1626 "folder1/".to_string(),
1627 vec![
1628 S3Object {
1629 key: "folder1/sub1.txt".to_string(),
1630 is_prefix: false,
1631 size: 50,
1632 last_modified: String::new(),
1633 storage_class: String::new(),
1634 },
1635 S3Object {
1636 key: "folder1/sub2.txt".to_string(),
1637 is_prefix: false,
1638 size: 75,
1639 last_modified: String::new(),
1640 storage_class: String::new(),
1641 },
1642 ],
1643 );
1644
1645 assert_eq!(state.calculate_total_object_rows(), 4);
1647 }
1648
1649 #[test]
1650 fn test_calculate_total_object_rows_nested_expansion() {
1651 let mut state = State::new();
1652 state.objects = vec![S3Object {
1653 key: "folder1/".to_string(),
1654 is_prefix: true,
1655 size: 0,
1656 last_modified: String::new(),
1657 storage_class: String::new(),
1658 }];
1659
1660 state.expanded_prefixes.insert("folder1/".to_string());
1662 state.prefix_preview.insert(
1663 "folder1/".to_string(),
1664 vec![S3Object {
1665 key: "folder1/folder2/".to_string(),
1666 is_prefix: true,
1667 size: 0,
1668 last_modified: String::new(),
1669 storage_class: String::new(),
1670 }],
1671 );
1672
1673 state
1675 .expanded_prefixes
1676 .insert("folder1/folder2/".to_string());
1677 state.prefix_preview.insert(
1678 "folder1/folder2/".to_string(),
1679 vec![
1680 S3Object {
1681 key: "folder1/folder2/file1.txt".to_string(),
1682 is_prefix: false,
1683 size: 100,
1684 last_modified: String::new(),
1685 storage_class: String::new(),
1686 },
1687 S3Object {
1688 key: "folder1/folder2/file2.txt".to_string(),
1689 is_prefix: false,
1690 size: 200,
1691 last_modified: String::new(),
1692 storage_class: String::new(),
1693 },
1694 ],
1695 );
1696
1697 assert_eq!(state.calculate_total_object_rows(), 4);
1699 }
1700
1701 #[test]
1702 fn test_scrollbar_needed_when_rows_exceed_visible_area() {
1703 let visible_rows = 10;
1704 let total_rows = 15;
1705
1706 assert!(total_rows > visible_rows);
1708 }
1709
1710 #[test]
1711 fn test_scrollbar_not_needed_when_rows_fit() {
1712 let visible_rows = 20;
1713 let total_rows = 10;
1714
1715 assert!(total_rows <= visible_rows);
1717 }
1718
1719 #[test]
1720 fn test_scroll_offset_adjusts_when_selection_below_viewport() {
1721 let mut state = State::new();
1722 state.bucket_visible_rows.set(10);
1723 state.bucket_scroll_offset = 0;
1724 state.selected_row = 15; let visible_rows = state.bucket_visible_rows.get();
1728 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1729 state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1730 }
1731
1732 assert_eq!(state.bucket_scroll_offset, 6); assert!(state.selected_row >= state.bucket_scroll_offset);
1734 assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1735 }
1736
1737 #[test]
1738 fn test_scroll_offset_adjusts_when_selection_above_viewport() {
1739 let mut state = State::new();
1740 state.bucket_visible_rows.set(10);
1741 state.bucket_scroll_offset = 10;
1742 state.selected_row = 5; if state.selected_row < state.bucket_scroll_offset {
1746 state.bucket_scroll_offset = state.selected_row;
1747 }
1748
1749 assert_eq!(state.bucket_scroll_offset, 5);
1750 assert!(state.selected_row >= state.bucket_scroll_offset);
1751 }
1752
1753 #[test]
1754 fn test_selection_stays_visible_during_navigation() {
1755 let mut state = State::new();
1756 state.bucket_visible_rows.set(10);
1757 state.bucket_scroll_offset = 0;
1758 state.selected_row = 0;
1759
1760 for _ in 0..15 {
1762 state.selected_row += 1;
1763 let visible_rows = state.bucket_visible_rows.get();
1764 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1765 state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1766 }
1767 }
1768
1769 assert_eq!(state.selected_row, 15);
1771 assert_eq!(state.bucket_scroll_offset, 6);
1772 assert!(state.selected_row >= state.bucket_scroll_offset);
1773 assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1774 }
1775
1776 #[test]
1777 fn test_scroll_offset_adjusts_after_jumping_to_parent() {
1778 let mut state = State::new();
1779 state.bucket_visible_rows.set(10);
1780 state.bucket_scroll_offset = 10; state.selected_row = 15; state.selected_row = 5;
1785
1786 let visible_rows = state.bucket_visible_rows.get();
1788 if state.selected_row < state.bucket_scroll_offset {
1789 state.bucket_scroll_offset = state.selected_row;
1790 } else if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1791 state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1792 }
1793
1794 assert_eq!(state.bucket_scroll_offset, 5);
1796 assert!(state.selected_row >= state.bucket_scroll_offset);
1797 assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1798 }
1799
1800 #[test]
1801 fn test_scroll_offset_adjusts_after_collapse() {
1802 let mut state = State::new();
1803 state.buckets.items = vec![S3Bucket {
1804 name: "bucket1".to_string(),
1805 region: "us-east-1".to_string(),
1806 creation_date: String::new(),
1807 }];
1808
1809 state.expanded_prefixes.insert("bucket1".to_string());
1811 let mut preview = vec![];
1812 for i in 0..20 {
1813 preview.push(S3Object {
1814 key: format!("file{}.txt", i),
1815 is_prefix: false,
1816 size: 100,
1817 last_modified: String::new(),
1818 storage_class: String::new(),
1819 });
1820 }
1821 state.bucket_preview.insert("bucket1".to_string(), preview);
1822
1823 state.bucket_visible_rows.set(10);
1824 state.bucket_scroll_offset = 10; state.selected_row = 15; state.expanded_prefixes.remove("bucket1");
1829
1830 state.selected_row = 0;
1832
1833 if state.selected_row < state.bucket_scroll_offset {
1835 state.bucket_scroll_offset = state.selected_row;
1836 }
1837
1838 assert_eq!(state.bucket_scroll_offset, 0);
1840 assert!(state.selected_row >= state.bucket_scroll_offset);
1841 }
1842
1843 #[test]
1844 fn test_object_scroll_offset_adjusts_after_jumping_to_parent() {
1845 let mut state = State::new();
1846 state.objects = vec![S3Object {
1847 key: "folder1/".to_string(),
1848 is_prefix: true,
1849 size: 0,
1850 last_modified: String::new(),
1851 storage_class: String::new(),
1852 }];
1853
1854 state.expanded_prefixes.insert("folder1/".to_string());
1856 let mut preview = vec![];
1857 for i in 0..20 {
1858 preview.push(S3Object {
1859 key: format!("folder1/file{}.txt", i),
1860 is_prefix: false,
1861 size: 100,
1862 last_modified: String::new(),
1863 storage_class: String::new(),
1864 });
1865 }
1866 state.prefix_preview.insert("folder1/".to_string(), preview);
1867
1868 state.object_visible_rows.set(10);
1869 state.object_scroll_offset = 10; state.selected_object = 15; state.selected_object = 0;
1874
1875 let visible_rows = state.object_visible_rows.get();
1877 if state.selected_object < state.object_scroll_offset {
1878 state.object_scroll_offset = state.selected_object;
1879 } else if state.selected_object >= state.object_scroll_offset + visible_rows {
1880 state.object_scroll_offset = state.selected_object.saturating_sub(visible_rows - 1);
1881 }
1882
1883 assert_eq!(state.object_scroll_offset, 0);
1885 assert!(state.selected_object >= state.object_scroll_offset);
1886 assert!(state.selected_object < state.object_scroll_offset + visible_rows);
1887 }
1888
1889 #[test]
1890 fn test_selection_below_viewport_becomes_visible() {
1891 let mut state = State::new();
1892 state.bucket_visible_rows.set(10);
1893 state.bucket_scroll_offset = 0; state.selected_row = 0;
1895
1896 state.selected_row = 20;
1898
1899 let visible_rows = state.bucket_visible_rows.get();
1901 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1902 state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1903 }
1904
1905 assert_eq!(state.bucket_scroll_offset, 11); assert!(state.selected_row >= state.bucket_scroll_offset);
1908 assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1909 }
1910
1911 #[test]
1912 fn test_scroll_keeps_selection_visible_when_navigating_down() {
1913 let mut state = State::new();
1914 state.buckets.items = vec![];
1915 for i in 0..50 {
1916 state.buckets.items.push(S3Bucket {
1917 name: format!("bucket{}", i),
1918 region: "us-east-1".to_string(),
1919 creation_date: String::new(),
1920 });
1921 }
1922
1923 state.bucket_visible_rows.set(10);
1924 state.bucket_scroll_offset = 0;
1925 state.selected_row = 0;
1926
1927 for _ in 0..25 {
1929 let total_rows = state.buckets.items.len();
1930 state.selected_row = (state.selected_row + 1).min(total_rows - 1);
1931
1932 let visible_rows = state.bucket_visible_rows.get();
1934 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1935 state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1936 }
1937 }
1938
1939 assert_eq!(state.selected_row, 25);
1941 assert_eq!(state.bucket_scroll_offset, 16);
1943 assert!(state.selected_row >= state.bucket_scroll_offset);
1945 assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1946 }
1947
1948 #[test]
1949 fn test_ctrl_d_adjusts_scroll_offset() {
1950 let mut state = State::new();
1951 state.buckets.items = vec![];
1952 for i in 0..50 {
1953 state.buckets.items.push(S3Bucket {
1954 name: format!("bucket{}", i),
1955 region: "us-east-1".to_string(),
1956 creation_date: String::new(),
1957 });
1958 }
1959
1960 state.bucket_visible_rows.set(10);
1961 state.bucket_scroll_offset = 0;
1962 state.selected_row = 5;
1963
1964 let total_rows = state.buckets.items.len();
1966 state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
1967
1968 let visible_rows = state.bucket_visible_rows.get();
1970 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1971 state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1972 }
1973
1974 assert_eq!(state.selected_row, 15);
1976 assert_eq!(state.bucket_scroll_offset, 6);
1978 assert!(state.selected_row >= state.bucket_scroll_offset);
1980 assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1981 }
1982
1983 #[test]
1984 fn test_ctrl_u_adjusts_scroll_offset() {
1985 let mut state = State::new();
1986 state.buckets.items = vec![];
1987 for i in 0..50 {
1988 state.buckets.items.push(S3Bucket {
1989 name: format!("bucket{}", i),
1990 region: "us-east-1".to_string(),
1991 creation_date: String::new(),
1992 });
1993 }
1994
1995 state.bucket_visible_rows.set(10);
1996 state.bucket_scroll_offset = 10;
1997 state.selected_row = 15;
1998
1999 state.selected_row = state.selected_row.saturating_sub(10);
2001
2002 if state.selected_row < state.bucket_scroll_offset {
2004 state.bucket_scroll_offset = state.selected_row;
2005 }
2006
2007 assert_eq!(state.selected_row, 5);
2009 assert_eq!(state.bucket_scroll_offset, 5);
2011 assert!(state.selected_row >= state.bucket_scroll_offset);
2013 }
2014
2015 #[test]
2016 fn test_ctrl_d_clamps_to_max_rows() {
2017 let mut state = State::new();
2018 state.buckets.items = vec![];
2019 for i in 0..20 {
2020 state.buckets.items.push(S3Bucket {
2021 name: format!("bucket{}", i),
2022 region: "us-east-1".to_string(),
2023 creation_date: String::new(),
2024 });
2025 }
2026
2027 state.bucket_visible_rows.set(10);
2028 state.bucket_scroll_offset = 5;
2029 state.selected_row = 15;
2030
2031 let total_rows = state.buckets.items.len();
2033 state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
2034
2035 assert_eq!(state.selected_row, 19);
2037 }
2038
2039 #[test]
2040 fn test_rounded_block_with_custom_border_style() {
2041 use ratatui::prelude::Rect;
2042 let block = rounded_block()
2043 .title(format_title("Properties"))
2044 .border_style(active_border());
2045 let area = Rect::new(0, 0, 70, 12);
2046 let inner = block.inner(area);
2047 assert_eq!(inner.width, 68);
2048 assert_eq!(inner.height, 10);
2049 }
2050
2051 #[test]
2052 fn test_column_width_accounts_for_header_text() {
2053 let header_name = "Name";
2055 let header_region = "Region";
2056 let header_date = "Creation date";
2057
2058 assert!(header_name.len() >= 4);
2060
2061 let region_with_sep = header_region.len() + 2;
2063 let date_with_sep = header_date.len() + 2;
2064
2065 assert!(region_with_sep >= header_region.len());
2066 assert!(date_with_sep >= header_date.len());
2067 }
2068
2069 #[test]
2070 fn test_title_formatting_no_double_dash() {
2071 let title = format!("{} ({})", "Directory buckets", 0);
2073 let formatted = format_title(&title);
2074
2075 assert!(formatted.starts_with("─ "));
2077 assert!(formatted.ends_with(" "));
2078 assert!(!formatted.contains("─ ─")); }
2080
2081 #[test]
2082 fn test_collapse_moves_to_parent() {
2083 let mut state = State::new();
2085 state.buckets.items = vec![S3Bucket {
2086 name: "bucket1".to_string(),
2087 region: "us-east-1".to_string(),
2088 creation_date: String::new(),
2089 }];
2090
2091 state.expanded_prefixes.insert("bucket1".to_string());
2093 state.bucket_preview.insert(
2094 "bucket1".to_string(),
2095 vec![S3Object {
2096 key: "folder1/".to_string(),
2097 is_prefix: true,
2098 size: 0,
2099 last_modified: String::new(),
2100 storage_class: String::new(),
2101 }],
2102 );
2103
2104 state.expanded_prefixes.insert("folder1/".to_string());
2106 state.prefix_preview.insert(
2107 "folder1/".to_string(),
2108 vec![S3Object {
2109 key: "folder1/file.txt".to_string(),
2110 is_prefix: false,
2111 size: 100,
2112 last_modified: String::new(),
2113 storage_class: String::new(),
2114 }],
2115 );
2116
2117 state.selected_row = 1;
2119
2120 assert!(state.expanded_prefixes.contains("folder1/"));
2122
2123 }
2126
2127 #[test]
2128 fn test_hierarchy_collapse_sequence() {
2129 let mut state = State::new();
2131 state.buckets.items = vec![S3Bucket {
2132 name: "bucket1".to_string(),
2133 region: "us-east-1".to_string(),
2134 creation_date: String::new(),
2135 }];
2136
2137 state.expanded_prefixes.insert("bucket1".to_string());
2139 state.bucket_preview.insert(
2140 "bucket1".to_string(),
2141 vec![S3Object {
2142 key: "level1/".to_string(),
2143 is_prefix: true,
2144 size: 0,
2145 last_modified: String::new(),
2146 storage_class: String::new(),
2147 }],
2148 );
2149
2150 state.expanded_prefixes.insert("level1/".to_string());
2151 state.prefix_preview.insert(
2152 "level1/".to_string(),
2153 vec![S3Object {
2154 key: "level1/level2/".to_string(),
2155 is_prefix: true,
2156 size: 0,
2157 last_modified: String::new(),
2158 storage_class: String::new(),
2159 }],
2160 );
2161
2162 state.expanded_prefixes.insert("level1/level2/".to_string());
2163 state.prefix_preview.insert(
2164 "level1/level2/".to_string(),
2165 vec![S3Object {
2166 key: "level1/level2/file.txt".to_string(),
2167 is_prefix: false,
2168 size: 100,
2169 last_modified: String::new(),
2170 storage_class: String::new(),
2171 }],
2172 );
2173
2174 assert_eq!(state.expanded_prefixes.len(), 3);
2176
2177 }
2180
2181 #[test]
2182 fn test_objects_collapse_jumps_to_parent() {
2183 let mut state = State::new();
2185 state.current_bucket = Some("test-bucket".to_string());
2186
2187 state.objects = vec![S3Object {
2189 key: "folder1/".to_string(),
2190 is_prefix: true,
2191 size: 0,
2192 last_modified: String::new(),
2193 storage_class: String::new(),
2194 }];
2195
2196 state.expanded_prefixes.insert("folder1/".to_string());
2197 state.prefix_preview.insert(
2198 "folder1/".to_string(),
2199 vec![S3Object {
2200 key: "folder1/folder2/".to_string(),
2201 is_prefix: true,
2202 size: 0,
2203 last_modified: String::new(),
2204 storage_class: String::new(),
2205 }],
2206 );
2207
2208 state
2209 .expanded_prefixes
2210 .insert("folder1/folder2/".to_string());
2211 state.prefix_preview.insert(
2212 "folder1/folder2/".to_string(),
2213 vec![S3Object {
2214 key: "folder1/folder2/file.txt".to_string(),
2215 is_prefix: false,
2216 size: 100,
2217 last_modified: String::new(),
2218 storage_class: String::new(),
2219 }],
2220 );
2221
2222 state.selected_object = 1;
2224
2225 assert!(state.expanded_prefixes.contains("folder1/folder2/"));
2227
2228 }
2232}