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 && (app.s3_state.bucket_preview.contains_key(&bucket.name)
560 || app.s3_state.bucket_errors.contains_key(&bucket.name));
561 let expand_indicator = if is_expanded {
562 format!("{} ", CURSOR_EXPANDED)
563 } else {
564 format!("{} ", CURSOR_COLLAPSED)
565 };
566
567 let formatted_date = if bucket.creation_date.contains('T') {
569 let parts: Vec<&str> = bucket.creation_date.split('T').collect();
571 if parts.len() == 2 {
572 let date = parts[0];
573 let time = parts[1]
574 .trim_end_matches('Z')
575 .split('.')
576 .next()
577 .unwrap_or(parts[1]);
578 format!("{} {} (UTC)", date, time)
579 } else {
580 bucket.creation_date.clone()
581 }
582 } else {
583 bucket.creation_date.clone()
584 };
585
586 fn count_expanded_children(
588 objects: &[S3Object],
589 expanded_prefixes: &std::collections::HashSet<String>,
590 prefix_preview: &std::collections::HashMap<String, Vec<S3Object>>,
591 ) -> usize {
592 let mut count = objects.len();
593 for obj in objects {
594 if obj.is_prefix && expanded_prefixes.contains(&obj.key) {
595 if let Some(nested) = prefix_preview.get(&obj.key) {
596 count +=
597 count_expanded_children(nested, expanded_prefixes, prefix_preview);
598 }
599 }
600 }
601 count
602 }
603
604 let mut row_idx = 0;
606 for i in 0..start_idx {
608 if let Some((_, b)) = all_filtered_buckets.get(i) {
609 row_idx += 1; if app.s3_state.expanded_prefixes.contains(&b.name) {
611 if let Some(preview) = app.s3_state.bucket_preview.get(&b.name) {
612 row_idx += count_expanded_children(
613 preview,
614 &app.s3_state.expanded_prefixes,
615 &app.s3_state.prefix_preview,
616 );
617 }
618 }
619 }
620 }
621 for i in 0..bucket_idx {
623 if let Some((_, b)) = filtered_buckets.get(i) {
624 row_idx += 1; if app.s3_state.expanded_prefixes.contains(&b.name) {
626 if let Some(preview) = app.s3_state.bucket_preview.get(&b.name) {
627 row_idx += count_expanded_children(
628 preview,
629 &app.s3_state.expanded_prefixes,
630 &app.s3_state.prefix_preview,
631 );
632 }
633 }
634 }
635 }
636
637 let style = if row_idx == app.s3_state.selected_row {
638 Style::default().bg(Color::DarkGray)
639 } else {
640 Style::default()
641 };
642
643 let cells: Vec<Cell> = app
644 .s3_bucket_visible_column_ids
645 .iter()
646 .enumerate()
647 .filter_map(|(i, col_id)| {
648 BucketColumn::from_id(col_id).map(|col| {
649 let content = match col {
650 BucketColumn::Name => {
651 format!("{}🪣 {}", expand_indicator, bucket.name)
652 }
653 BucketColumn::Region => {
654 if bucket.region.is_empty() {
655 "?".to_string()
656 } else {
657 bucket.region.clone()
658 }
659 }
660 BucketColumn::CreationDate => formatted_date.clone(),
661 };
662 let cell_content = if i > 0 {
663 format!("⋮ {}", content)
664 } else {
665 content
666 };
667 Cell::from(cell_content)
668 })
669 })
670 .collect();
671
672 let mut result = vec![Row::new(cells).height(1).style(style)];
673 let mut child_row_idx = row_idx + 1;
674
675 if is_expanded {
676 if let Some(error) = app.s3_state.bucket_errors.get(&bucket.name) {
677 let max_width = 120;
680 let error_lines: Vec<String> = if error.len() > max_width {
681 error
682 .as_bytes()
683 .chunks(max_width)
684 .map(|chunk| String::from_utf8_lossy(chunk).to_string())
685 .collect()
686 } else {
687 vec![error.clone()]
688 };
689
690 for (line_idx, error_line) in error_lines.iter().enumerate() {
691 let error_cells: Vec<Cell> = app
692 .s3_bucket_visible_column_ids
693 .iter()
694 .enumerate()
695 .map(|(i, _col)| {
696 if i == 0 {
697 if line_idx == 0 {
698 Cell::from(format!(" ⚠️ {}", error_line))
699 .style(red_text())
700 } else {
701 Cell::from(format!(" {}", error_line)).style(red_text())
702 }
703 } else {
704 Cell::from("")
705 }
706 })
707 .collect();
708 result.push(Row::new(error_cells).height(1));
709 }
710 } else if let Some(preview) = app.s3_state.bucket_preview.get(&bucket.name) {
712 fn render_objects_recursive<'a>(
714 objects: &'a [S3Object],
715 app: &'a App,
716 child_row_idx: &mut usize,
717 result: &mut Vec<Row<'a>>,
718 parent_key: &str,
719 is_last: &[bool],
720 ) {
721 for (idx, obj) in objects.iter().enumerate() {
722 let is_last_item = idx == objects.len() - 1;
723 let obj_is_expanded = app.s3_state.expanded_prefixes.contains(&obj.key);
724
725 let mut prefix = String::new();
727 for &last in is_last.iter() {
728 prefix.push_str(if last { " " } else { "│ " });
729 }
730
731 let tree_char = if is_last_item { "╰─" } else { "├─" };
732 let expand_char = if obj.is_prefix {
733 if obj_is_expanded {
734 CURSOR_EXPANDED
735 } else {
736 CURSOR_COLLAPSED
737 }
738 } else {
739 ""
740 };
741
742 let icon = if obj.is_prefix { "📁" } else { "📄" };
743 let display_key = obj.key.strip_prefix(parent_key).unwrap_or(&obj.key);
744
745 let child_style = if *child_row_idx == app.s3_state.selected_row {
746 Style::default().bg(Color::DarkGray)
747 } else {
748 Style::default()
749 };
750
751 let formatted_date = format_iso_timestamp(&obj.last_modified);
752
753 let child_cells: Vec<Cell> = app
754 .s3_bucket_visible_column_ids
755 .iter()
756 .enumerate()
757 .filter_map(|(i, col_id)| {
758 BucketColumn::from_id(col_id).map(|col| {
759 let content = match col {
760 BucketColumn::Name => format!(
761 "{}{}{} {} {}",
762 prefix, tree_char, expand_char, icon, display_key
763 ),
764 BucketColumn::Region => String::new(),
765 BucketColumn::CreationDate => formatted_date.clone(),
766 };
767 if i > 0 {
768 Cell::from(format!("⋮ {}", content))
769 } else {
770 Cell::from(content)
771 }
772 })
773 })
774 .collect();
775 result.push(Row::new(child_cells).style(child_style));
776 *child_row_idx += 1;
777
778 if obj.is_prefix && obj_is_expanded {
780 if let Some(nested_preview) =
781 app.s3_state.prefix_preview.get(&obj.key)
782 {
783 let mut new_is_last = is_last.to_vec();
784 new_is_last.push(is_last_item);
785 render_objects_recursive(
786 nested_preview,
787 app,
788 child_row_idx,
789 result,
790 &obj.key,
791 &new_is_last,
792 );
793 }
794 }
795 }
796 }
797
798 render_objects_recursive(
799 preview,
800 app,
801 &mut child_row_idx,
802 &mut result,
803 "",
804 &[],
805 );
806 }
807 }
808
809 result
810 })
811 .skip(
812 app.s3_state
813 .bucket_scroll_offset
814 .saturating_sub(first_bucket_row_idx),
815 )
816 .take(app.s3_state.bucket_visible_rows.get())
817 .collect();
818
819 let widths: Vec<Constraint> = app
820 .s3_bucket_visible_column_ids
821 .iter()
822 .enumerate()
823 .filter_map(|(i, col_id)| {
824 BucketColumn::from_id(col_id).map(|col| {
825 let base_width = match col {
826 BucketColumn::Name => max_name_width,
827 BucketColumn::Region => max_region_width,
828 BucketColumn::CreationDate => max_date_width,
829 };
830 let width = if i > 0 { base_width + 2 } else { base_width };
832 Constraint::Length(width as u16)
833 })
834 })
835 .collect();
836
837 let is_active = app.mode != Mode::ColumnSelector;
838 let border_color = if is_active {
839 Color::Green
840 } else {
841 Color::White
842 };
843
844 let table = Table::new(rows, widths)
845 .header(header)
846 .column_spacing(1)
847 .block(titled_block(title).border_style(Style::default().fg(border_color)));
848
849 frame.render_widget(table, chunks[2]);
850
851 let total_rows = app.s3_state.calculate_total_bucket_rows();
853 let visible_rows = chunks[2].height.saturating_sub(3) as usize; if total_rows > visible_rows {
855 render_scrollbar(
856 frame,
857 chunks[2].inner(Margin {
858 vertical: 1,
859 horizontal: 0,
860 }),
861 total_rows,
862 app.s3_state.selected_row,
863 );
864 }
865}
866
867fn render_objects(frame: &mut Frame, app: &App, area: Rect) {
868 let show_filter = app.s3_state.object_tab == ObjectTab::Objects;
869
870 let chunks = if show_filter {
871 vertical(
872 [
873 Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ],
877 area,
878 )
879 } else {
880 vertical(
881 [
882 Constraint::Length(1), Constraint::Min(0), ],
885 area,
886 )
887 };
888
889 let content_area_idx = if show_filter { 2 } else { 1 };
891 let visible_rows = chunks[content_area_idx].height.saturating_sub(3) as usize;
892 app.s3_state.object_visible_rows.set(visible_rows);
893
894 let available_tabs = if app.s3_state.prefix_stack.is_empty() {
896 ObjectTab::all()
898 } else {
899 vec![ObjectTab::Objects, ObjectTab::Properties]
901 };
902
903 let tab_tuples: Vec<(&str, ObjectTab)> = available_tabs
904 .iter()
905 .map(|tab| (tab.name(), *tab))
906 .collect();
907
908 frame.render_widget(Clear, chunks[0]);
909 render_tabs(frame, chunks[0], &tab_tuples, &app.s3_state.object_tab);
910
911 if app.s3_state.object_tab == ObjectTab::Objects {
913 let cursor = get_cursor(app.mode == Mode::FilterInput);
914 let filter_text = if app.s3_state.object_filter.is_empty() && app.mode != Mode::FilterInput
915 {
916 vec![
917 Span::styled(
918 "Find objects by prefix",
919 Style::default().fg(Color::DarkGray),
920 ),
921 Span::styled(cursor, Style::default().fg(Color::Yellow)),
922 ]
923 } else {
924 vec![
925 Span::raw(&app.s3_state.object_filter),
926 Span::styled(cursor, Style::default().fg(Color::Yellow)),
927 ]
928 };
929 let filter = filter_area(filter_text, app.mode == Mode::FilterInput);
930 frame.render_widget(filter, chunks[1]);
931 }
932
933 let content_idx = if show_filter { 2 } else { 1 };
935 match app.s3_state.object_tab {
936 ObjectTab::Objects => render_objects_table(frame, app, chunks[content_idx]),
937 ObjectTab::Properties => render_bucket_properties(frame, app, chunks[content_idx]),
938 _ => {
939 let placeholder =
941 Paragraph::new(format!("{} - Coming soon", app.s3_state.object_tab.name()))
942 .block(rounded_block().border_style(active_border()))
943 .style(Style::default().fg(Color::Gray));
944 frame.render_widget(placeholder, chunks[content_idx]);
945 }
946 }
947}
948
949fn render_objects_table(frame: &mut Frame, app: &App, area: Rect) {
950 let filtered_objects: Vec<_> = app
952 .s3_state
953 .objects
954 .iter()
955 .enumerate()
956 .filter(|(_, obj)| {
957 if app.s3_state.object_filter.is_empty() {
958 true
959 } else {
960 let name = obj.key.trim_start_matches(
961 &app.s3_state
962 .prefix_stack
963 .last()
964 .cloned()
965 .unwrap_or_default(),
966 );
967 name.to_lowercase()
968 .contains(&app.s3_state.object_filter.to_lowercase())
969 }
970 })
971 .collect();
972
973 let count = filtered_objects.len();
974 let title = format_title(&format!("Objects ({})", count));
975
976 let max_name_width = filtered_objects
978 .iter()
979 .map(|(_, obj)| {
980 let name = obj.key.trim_start_matches(
981 &app.s3_state
982 .prefix_stack
983 .last()
984 .cloned()
985 .unwrap_or_default(),
986 );
987 name.len() + 4 })
989 .max()
990 .unwrap_or(30)
991 .max(30) as u16;
992
993 let rows: Vec<Row> = filtered_objects
994 .iter()
995 .flat_map(|(idx, obj)| {
996 let icon = if obj.is_prefix { "📁" } else { "📄" };
997
998 let expand_indicator = if obj.is_prefix {
1000 if app.s3_state.expanded_prefixes.contains(&obj.key) {
1001 format!("{} ", CURSOR_EXPANDED)
1002 } else {
1003 format!("{} ", CURSOR_COLLAPSED)
1004 }
1005 } else {
1006 String::new()
1007 };
1008
1009 let name = obj.key.trim_start_matches(
1010 &app.s3_state
1011 .prefix_stack
1012 .last()
1013 .cloned()
1014 .unwrap_or_default(),
1015 );
1016 let display_name = format!("{}{} {}", expand_indicator, icon, name);
1017 let obj_type = if obj.is_prefix { "Folder" } else { "File" };
1018 let size = if obj.is_prefix {
1019 String::new()
1020 } else {
1021 format_bytes(obj.size)
1022 };
1023
1024 let datetime = format_iso_timestamp(&obj.last_modified);
1026
1027 let storage = if obj.storage_class.is_empty() {
1029 String::new()
1030 } else {
1031 obj.storage_class
1032 .chars()
1033 .next()
1034 .unwrap()
1035 .to_uppercase()
1036 .to_string()
1037 + &obj.storage_class[1..].to_lowercase()
1038 };
1039
1040 let mut row_idx = *idx;
1042 for i in 0..*idx {
1043 if let Some(prev_obj) = app.s3_state.objects.get(i) {
1044 if prev_obj.is_prefix && app.s3_state.expanded_prefixes.contains(&prev_obj.key)
1045 {
1046 if let Some(preview) = app.s3_state.prefix_preview.get(&prev_obj.key) {
1047 row_idx += preview.len();
1048 }
1049 }
1050 }
1051 }
1052
1053 let style = if row_idx == app.s3_state.selected_object {
1054 Style::default().bg(Color::DarkGray)
1055 } else {
1056 Style::default()
1057 };
1058
1059 let mut result = vec![Row::new(vec![
1060 Cell::from(display_name),
1061 Cell::from(format!("⋮ {}", obj_type)),
1062 Cell::from(format!("⋮ {}", datetime)),
1063 Cell::from(format!("⋮ {}", size)),
1064 Cell::from(format!("⋮ {}", storage)),
1065 ])
1066 .style(style)];
1067
1068 let mut child_row_idx = row_idx + 1;
1069
1070 if obj.is_prefix && app.s3_state.expanded_prefixes.contains(&obj.key) {
1071 if let Some(preview) = app.s3_state.prefix_preview.get(&obj.key) {
1072 fn render_nested_objects<'a>(
1074 objects: &'a [S3Object],
1075 app: &'a App,
1076 child_row_idx: &mut usize,
1077 result: &mut Vec<Row<'a>>,
1078 parent_key: &str,
1079 is_last: &[bool],
1080 ) {
1081 for (child_idx, preview_obj) in objects.iter().enumerate() {
1082 let is_last_child = child_idx == objects.len() - 1;
1083 let obj_is_expanded =
1084 app.s3_state.expanded_prefixes.contains(&preview_obj.key);
1085
1086 let mut prefix = String::new();
1088 for &last in is_last.iter() {
1089 prefix.push_str(if last { " " } else { "│ " });
1090 }
1091
1092 let tree_char = if is_last_child { "╰─" } else { "├─" };
1093 let child_expand = if preview_obj.is_prefix {
1094 if obj_is_expanded {
1095 CURSOR_EXPANDED
1096 } else {
1097 CURSOR_COLLAPSED
1098 }
1099 } else {
1100 ""
1101 };
1102 let child_icon = if preview_obj.is_prefix {
1103 "📁"
1104 } else {
1105 "📄"
1106 };
1107 let child_name = preview_obj
1108 .key
1109 .strip_prefix(parent_key)
1110 .unwrap_or(&preview_obj.key);
1111
1112 let child_type = if preview_obj.is_prefix {
1113 "Folder"
1114 } else {
1115 "File"
1116 };
1117 let child_size = if preview_obj.is_prefix {
1118 String::new()
1119 } else {
1120 format_bytes(preview_obj.size)
1121 };
1122 let child_datetime = format_iso_timestamp(&preview_obj.last_modified);
1123 let child_storage = if preview_obj.storage_class.is_empty() {
1124 String::new()
1125 } else {
1126 preview_obj
1127 .storage_class
1128 .chars()
1129 .next()
1130 .unwrap()
1131 .to_uppercase()
1132 .to_string()
1133 + &preview_obj.storage_class[1..].to_lowercase()
1134 };
1135
1136 let child_style = if *child_row_idx == app.s3_state.selected_object {
1137 Style::default().bg(Color::DarkGray)
1138 } else {
1139 Style::default()
1140 };
1141
1142 result.push(
1143 Row::new(vec![
1144 Cell::from(format!(
1145 "{}{}{} {} {}",
1146 prefix, tree_char, child_expand, child_icon, child_name
1147 )),
1148 Cell::from(format!("⋮ {}", child_type)),
1149 Cell::from(format!("⋮ {}", child_datetime)),
1150 Cell::from(format!("⋮ {}", child_size)),
1151 Cell::from(format!("⋮ {}", child_storage)),
1152 ])
1153 .style(child_style),
1154 );
1155 *child_row_idx += 1;
1156
1157 if preview_obj.is_prefix && obj_is_expanded {
1159 if let Some(nested_preview) =
1160 app.s3_state.prefix_preview.get(&preview_obj.key)
1161 {
1162 let mut new_is_last = is_last.to_vec();
1163 new_is_last.push(is_last_child);
1164 render_nested_objects(
1165 nested_preview,
1166 app,
1167 child_row_idx,
1168 result,
1169 &preview_obj.key,
1170 &new_is_last,
1171 );
1172 }
1173 }
1174 }
1175 }
1176
1177 render_nested_objects(
1178 preview,
1179 app,
1180 &mut child_row_idx,
1181 &mut result,
1182 &obj.key,
1183 &[],
1184 );
1185 }
1186 }
1187
1188 result
1189 })
1190 .skip(app.s3_state.object_scroll_offset)
1191 .take(app.s3_state.object_visible_rows.get())
1192 .collect();
1193
1194 crate::ui::table::render_tree_table(
1195 frame,
1196 area,
1197 title,
1198 vec!["Name", "Type", "Last modified", "Size", "Storage class"],
1199 rows,
1200 vec![
1201 Constraint::Length(max_name_width),
1202 Constraint::Length(10),
1203 Constraint::Length(UTC_TIMESTAMP_WIDTH),
1204 Constraint::Length(12),
1205 Constraint::Length(15),
1206 ],
1207 true,
1208 );
1209
1210 let total_rows = app.s3_state.calculate_total_object_rows();
1212 let visible_rows = area.height.saturating_sub(3) as usize;
1213 if total_rows > visible_rows {
1214 render_scrollbar(
1215 frame,
1216 area.inner(Margin {
1217 vertical: 1,
1218 horizontal: 0,
1219 }),
1220 total_rows,
1221 app.s3_state.selected_object,
1222 );
1223 }
1224}
1225
1226fn render_bucket_properties(frame: &mut Frame, app: &App, area: Rect) {
1227 let bucket_name = app.s3_state.current_bucket.as_ref().unwrap();
1228 let bucket = app
1229 .s3_state
1230 .buckets
1231 .items
1232 .iter()
1233 .find(|b| &b.name == bucket_name);
1234
1235 let mut lines = vec![];
1236
1237 let block = rounded_block()
1238 .title(format_title("Properties"))
1239 .border_style(active_border());
1240 let inner = block.inner(area);
1241
1242 lines.push(section_header("Bucket overview", inner.width));
1244 if let Some(b) = bucket {
1245 let region = if b.region.is_empty() { "?" } else { &b.region };
1246 let formatted_date = if b.creation_date.contains('T') {
1247 let parts: Vec<&str> = b.creation_date.split('T').collect();
1248 if parts.len() == 2 {
1249 format!(
1250 "{} {} (UTC)",
1251 parts[0],
1252 parts[1]
1253 .trim_end_matches('Z')
1254 .split('.')
1255 .next()
1256 .unwrap_or(parts[1])
1257 )
1258 } else {
1259 b.creation_date.clone()
1260 }
1261 } else {
1262 b.creation_date.clone()
1263 };
1264 lines.push(Line::from(vec![
1265 Span::styled(
1266 "AWS Region: ",
1267 Style::default().add_modifier(Modifier::BOLD),
1268 ),
1269 Span::raw(region),
1270 ]));
1271 lines.push(Line::from(vec![
1272 Span::styled(
1273 "Amazon Resource Name (ARN): ",
1274 Style::default().add_modifier(Modifier::BOLD),
1275 ),
1276 Span::raw(format!("arn:aws:s3:::{}", bucket_name)),
1277 ]));
1278 lines.push(Line::from(vec![
1279 Span::styled(
1280 "Creation date: ",
1281 Style::default().add_modifier(Modifier::BOLD),
1282 ),
1283 Span::raw(formatted_date),
1284 ]));
1285 }
1286 lines.push(Line::from(""));
1287
1288 lines.push(section_header("Tags (0)", inner.width));
1290 lines.push(Line::from("No tags associated with this resource."));
1291 lines.push(Line::from(""));
1292
1293 lines.push(section_header("Default encryption", inner.width));
1295 lines.push(Line::from(vec![
1296 Span::styled(
1297 "Encryption type: ",
1298 Style::default().add_modifier(Modifier::BOLD),
1299 ),
1300 Span::raw("Server-side encryption with Amazon S3 managed keys (SSE-S3)"),
1301 ]));
1302 lines.push(Line::from(vec![
1303 Span::styled(
1304 "Bucket Key: ",
1305 Style::default().add_modifier(Modifier::BOLD),
1306 ),
1307 Span::raw("Disabled"),
1308 ]));
1309 lines.push(Line::from(""));
1310
1311 lines.push(section_header("Server access logging", inner.width));
1313 lines.push(Line::from("Disabled"));
1314 lines.push(Line::from(""));
1315
1316 lines.push(section_header("AWS CloudTrail data events", inner.width));
1318 lines.push(Line::from("Configure in CloudTrail console"));
1319 lines.push(Line::from(""));
1320
1321 lines.push(section_header("Amazon EventBridge", inner.width));
1323 lines.push(Line::from(vec![
1324 Span::styled(
1325 "Send notifications to Amazon EventBridge: ",
1326 Style::default().add_modifier(Modifier::BOLD),
1327 ),
1328 Span::raw("Off"),
1329 ]));
1330 lines.push(Line::from(""));
1331
1332 lines.push(section_header("Transfer acceleration", inner.width));
1334 lines.push(Line::from("Disabled"));
1335 lines.push(Line::from(""));
1336
1337 lines.push(section_header("Object Lock", inner.width));
1339 lines.push(Line::from("Disabled"));
1340 lines.push(Line::from(""));
1341
1342 lines.push(section_header("Requester pays", inner.width));
1344 lines.push(Line::from("Disabled"));
1345 lines.push(Line::from(""));
1346
1347 lines.push(section_header("Static website hosting", inner.width));
1349 lines.push(Line::from("Disabled"));
1350
1351 let paragraph = Paragraph::new(lines)
1352 .block(block)
1353 .wrap(Wrap { trim: false })
1354 .scroll((app.s3_state.properties_scroll, 0));
1355
1356 frame.render_widget(paragraph, area);
1357
1358 let content_height = 40; if content_height > area.height.saturating_sub(2) {
1361 render_scrollbar(
1362 frame,
1363 area.inner(Margin {
1364 vertical: 1,
1365 horizontal: 0,
1366 }),
1367 content_height as usize,
1368 app.s3_state.properties_scroll as usize,
1369 );
1370 }
1371}
1372
1373pub async fn load_s3_buckets(app: &mut App) -> anyhow::Result<()> {
1375 let buckets = app.s3_client.list_buckets().await?;
1376 app.s3_state.buckets.items = buckets
1377 .into_iter()
1378 .map(|(name, region, date)| AppS3Bucket {
1379 name,
1380 region,
1381 creation_date: date,
1382 })
1383 .collect();
1384 Ok(())
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use super::*;
1390
1391 #[test]
1392 fn test_calculate_total_bucket_rows_no_expansion() {
1393 let mut state = State::new();
1394 state.buckets.items = vec![
1395 S3Bucket {
1396 name: "bucket1".to_string(),
1397 region: "us-east-1".to_string(),
1398 creation_date: String::new(),
1399 },
1400 S3Bucket {
1401 name: "bucket2".to_string(),
1402 region: "us-west-2".to_string(),
1403 creation_date: String::new(),
1404 },
1405 ];
1406
1407 assert_eq!(state.calculate_total_bucket_rows(), 2);
1408 }
1409
1410 #[test]
1411 fn test_calculate_total_bucket_rows_with_expansion() {
1412 let mut state = State::new();
1413 state.buckets.items = vec![S3Bucket {
1414 name: "bucket1".to_string(),
1415 region: "us-east-1".to_string(),
1416 creation_date: String::new(),
1417 }];
1418
1419 state.expanded_prefixes.insert("bucket1".to_string());
1421 state.bucket_preview.insert(
1422 "bucket1".to_string(),
1423 vec![
1424 S3Object {
1425 key: "file1.txt".to_string(),
1426 is_prefix: false,
1427 size: 100,
1428 last_modified: String::new(),
1429 storage_class: String::new(),
1430 },
1431 S3Object {
1432 key: "folder1/".to_string(),
1433 is_prefix: true,
1434 size: 0,
1435 last_modified: String::new(),
1436 storage_class: String::new(),
1437 },
1438 ],
1439 );
1440
1441 assert_eq!(state.calculate_total_bucket_rows(), 3);
1443 }
1444
1445 #[test]
1446 fn test_calculate_total_bucket_rows_nested_expansion() {
1447 let mut state = State::new();
1448 state.buckets.items = vec![S3Bucket {
1449 name: "bucket1".to_string(),
1450 region: "us-east-1".to_string(),
1451 creation_date: String::new(),
1452 }];
1453
1454 state.expanded_prefixes.insert("bucket1".to_string());
1456 state.bucket_preview.insert(
1457 "bucket1".to_string(),
1458 vec![S3Object {
1459 key: "folder1/".to_string(),
1460 is_prefix: true,
1461 size: 0,
1462 last_modified: String::new(),
1463 storage_class: String::new(),
1464 }],
1465 );
1466
1467 state.expanded_prefixes.insert("folder1/".to_string());
1469 state.prefix_preview.insert(
1470 "folder1/".to_string(),
1471 vec![
1472 S3Object {
1473 key: "folder1/file1.txt".to_string(),
1474 is_prefix: false,
1475 size: 100,
1476 last_modified: String::new(),
1477 storage_class: String::new(),
1478 },
1479 S3Object {
1480 key: "folder1/file2.txt".to_string(),
1481 is_prefix: false,
1482 size: 200,
1483 last_modified: String::new(),
1484 storage_class: String::new(),
1485 },
1486 ],
1487 );
1488
1489 assert_eq!(state.calculate_total_bucket_rows(), 4);
1491 }
1492
1493 #[test]
1494 fn test_calculate_total_bucket_rows_deeply_nested() {
1495 let mut state = State::new();
1496 state.buckets.items = vec![S3Bucket {
1497 name: "bucket1".to_string(),
1498 region: "us-east-1".to_string(),
1499 creation_date: String::new(),
1500 }];
1501
1502 state.expanded_prefixes.insert("bucket1".to_string());
1504 state.bucket_preview.insert(
1505 "bucket1".to_string(),
1506 vec![S3Object {
1507 key: "folder1/".to_string(),
1508 is_prefix: true,
1509 size: 0,
1510 last_modified: String::new(),
1511 storage_class: String::new(),
1512 }],
1513 );
1514
1515 state.expanded_prefixes.insert("folder1/".to_string());
1517 state.prefix_preview.insert(
1518 "folder1/".to_string(),
1519 vec![S3Object {
1520 key: "folder1/folder2/".to_string(),
1521 is_prefix: true,
1522 size: 0,
1523 last_modified: String::new(),
1524 storage_class: String::new(),
1525 }],
1526 );
1527
1528 state
1530 .expanded_prefixes
1531 .insert("folder1/folder2/".to_string());
1532 state.prefix_preview.insert(
1533 "folder1/folder2/".to_string(),
1534 vec![
1535 S3Object {
1536 key: "folder1/folder2/file1.txt".to_string(),
1537 is_prefix: false,
1538 size: 100,
1539 last_modified: String::new(),
1540 storage_class: String::new(),
1541 },
1542 S3Object {
1543 key: "folder1/folder2/file2.txt".to_string(),
1544 is_prefix: false,
1545 size: 200,
1546 last_modified: String::new(),
1547 storage_class: String::new(),
1548 },
1549 S3Object {
1550 key: "folder1/folder2/file3.txt".to_string(),
1551 is_prefix: false,
1552 size: 300,
1553 last_modified: String::new(),
1554 storage_class: String::new(),
1555 },
1556 ],
1557 );
1558
1559 assert_eq!(state.calculate_total_bucket_rows(), 6);
1561 }
1562
1563 #[test]
1564 fn test_calculate_total_object_rows_no_expansion() {
1565 let mut state = State::new();
1566 state.objects = vec![
1567 S3Object {
1568 key: "folder1/".to_string(),
1569 is_prefix: true,
1570 size: 0,
1571 last_modified: String::new(),
1572 storage_class: String::new(),
1573 },
1574 S3Object {
1575 key: "folder2/".to_string(),
1576 is_prefix: true,
1577 size: 0,
1578 last_modified: String::new(),
1579 storage_class: String::new(),
1580 },
1581 S3Object {
1582 key: "file.txt".to_string(),
1583 is_prefix: false,
1584 size: 100,
1585 last_modified: String::new(),
1586 storage_class: String::new(),
1587 },
1588 ];
1589
1590 assert_eq!(state.calculate_total_object_rows(), 3);
1591 }
1592
1593 #[test]
1594 fn test_calculate_total_object_rows_with_expansion() {
1595 let mut state = State::new();
1596 state.objects = vec![
1597 S3Object {
1598 key: "folder1/".to_string(),
1599 is_prefix: true,
1600 size: 0,
1601 last_modified: String::new(),
1602 storage_class: String::new(),
1603 },
1604 S3Object {
1605 key: "file.txt".to_string(),
1606 is_prefix: false,
1607 size: 100,
1608 last_modified: String::new(),
1609 storage_class: String::new(),
1610 },
1611 ];
1612
1613 state.expanded_prefixes.insert("folder1/".to_string());
1615 state.prefix_preview.insert(
1616 "folder1/".to_string(),
1617 vec![
1618 S3Object {
1619 key: "folder1/sub1.txt".to_string(),
1620 is_prefix: false,
1621 size: 50,
1622 last_modified: String::new(),
1623 storage_class: String::new(),
1624 },
1625 S3Object {
1626 key: "folder1/sub2.txt".to_string(),
1627 is_prefix: false,
1628 size: 75,
1629 last_modified: String::new(),
1630 storage_class: String::new(),
1631 },
1632 ],
1633 );
1634
1635 assert_eq!(state.calculate_total_object_rows(), 4);
1637 }
1638
1639 #[test]
1640 fn test_calculate_total_object_rows_nested_expansion() {
1641 let mut state = State::new();
1642 state.objects = vec![S3Object {
1643 key: "folder1/".to_string(),
1644 is_prefix: true,
1645 size: 0,
1646 last_modified: String::new(),
1647 storage_class: String::new(),
1648 }];
1649
1650 state.expanded_prefixes.insert("folder1/".to_string());
1652 state.prefix_preview.insert(
1653 "folder1/".to_string(),
1654 vec![S3Object {
1655 key: "folder1/folder2/".to_string(),
1656 is_prefix: true,
1657 size: 0,
1658 last_modified: String::new(),
1659 storage_class: String::new(),
1660 }],
1661 );
1662
1663 state
1665 .expanded_prefixes
1666 .insert("folder1/folder2/".to_string());
1667 state.prefix_preview.insert(
1668 "folder1/folder2/".to_string(),
1669 vec![
1670 S3Object {
1671 key: "folder1/folder2/file1.txt".to_string(),
1672 is_prefix: false,
1673 size: 100,
1674 last_modified: String::new(),
1675 storage_class: String::new(),
1676 },
1677 S3Object {
1678 key: "folder1/folder2/file2.txt".to_string(),
1679 is_prefix: false,
1680 size: 200,
1681 last_modified: String::new(),
1682 storage_class: String::new(),
1683 },
1684 ],
1685 );
1686
1687 assert_eq!(state.calculate_total_object_rows(), 4);
1689 }
1690
1691 #[test]
1692 fn test_scrollbar_needed_when_rows_exceed_visible_area() {
1693 let visible_rows = 10;
1694 let total_rows = 15;
1695
1696 assert!(total_rows > visible_rows);
1698 }
1699
1700 #[test]
1701 fn test_scrollbar_not_needed_when_rows_fit() {
1702 let visible_rows = 20;
1703 let total_rows = 10;
1704
1705 assert!(total_rows <= visible_rows);
1707 }
1708
1709 #[test]
1710 fn test_scroll_offset_adjusts_when_selection_below_viewport() {
1711 let mut state = State::new();
1712 state.bucket_visible_rows.set(10);
1713 state.bucket_scroll_offset = 0;
1714 state.selected_row = 15; let visible_rows = state.bucket_visible_rows.get();
1718 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1719 state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1720 }
1721
1722 assert_eq!(state.bucket_scroll_offset, 6); assert!(state.selected_row >= state.bucket_scroll_offset);
1724 assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1725 }
1726
1727 #[test]
1728 fn test_scroll_offset_adjusts_when_selection_above_viewport() {
1729 let mut state = State::new();
1730 state.bucket_visible_rows.set(10);
1731 state.bucket_scroll_offset = 10;
1732 state.selected_row = 5; if state.selected_row < state.bucket_scroll_offset {
1736 state.bucket_scroll_offset = state.selected_row;
1737 }
1738
1739 assert_eq!(state.bucket_scroll_offset, 5);
1740 assert!(state.selected_row >= state.bucket_scroll_offset);
1741 }
1742
1743 #[test]
1744 fn test_selection_stays_visible_during_navigation() {
1745 let mut state = State::new();
1746 state.bucket_visible_rows.set(10);
1747 state.bucket_scroll_offset = 0;
1748 state.selected_row = 0;
1749
1750 for _ in 0..15 {
1752 state.selected_row += 1;
1753 let visible_rows = state.bucket_visible_rows.get();
1754 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1755 state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1756 }
1757 }
1758
1759 assert_eq!(state.selected_row, 15);
1761 assert_eq!(state.bucket_scroll_offset, 6);
1762 assert!(state.selected_row >= state.bucket_scroll_offset);
1763 assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1764 }
1765
1766 #[test]
1767 fn test_scroll_offset_adjusts_after_jumping_to_parent() {
1768 let mut state = State::new();
1769 state.bucket_visible_rows.set(10);
1770 state.bucket_scroll_offset = 10; state.selected_row = 15; state.selected_row = 5;
1775
1776 let visible_rows = state.bucket_visible_rows.get();
1778 if state.selected_row < state.bucket_scroll_offset {
1779 state.bucket_scroll_offset = state.selected_row;
1780 } else if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1781 state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1782 }
1783
1784 assert_eq!(state.bucket_scroll_offset, 5);
1786 assert!(state.selected_row >= state.bucket_scroll_offset);
1787 assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1788 }
1789
1790 #[test]
1791 fn test_scroll_offset_adjusts_after_collapse() {
1792 let mut state = State::new();
1793 state.buckets.items = vec![S3Bucket {
1794 name: "bucket1".to_string(),
1795 region: "us-east-1".to_string(),
1796 creation_date: String::new(),
1797 }];
1798
1799 state.expanded_prefixes.insert("bucket1".to_string());
1801 let mut preview = vec![];
1802 for i in 0..20 {
1803 preview.push(S3Object {
1804 key: format!("file{}.txt", i),
1805 is_prefix: false,
1806 size: 100,
1807 last_modified: String::new(),
1808 storage_class: String::new(),
1809 });
1810 }
1811 state.bucket_preview.insert("bucket1".to_string(), preview);
1812
1813 state.bucket_visible_rows.set(10);
1814 state.bucket_scroll_offset = 10; state.selected_row = 15; state.expanded_prefixes.remove("bucket1");
1819
1820 state.selected_row = 0;
1822
1823 if state.selected_row < state.bucket_scroll_offset {
1825 state.bucket_scroll_offset = state.selected_row;
1826 }
1827
1828 assert_eq!(state.bucket_scroll_offset, 0);
1830 assert!(state.selected_row >= state.bucket_scroll_offset);
1831 }
1832
1833 #[test]
1834 fn test_object_scroll_offset_adjusts_after_jumping_to_parent() {
1835 let mut state = State::new();
1836 state.objects = vec![S3Object {
1837 key: "folder1/".to_string(),
1838 is_prefix: true,
1839 size: 0,
1840 last_modified: String::new(),
1841 storage_class: String::new(),
1842 }];
1843
1844 state.expanded_prefixes.insert("folder1/".to_string());
1846 let mut preview = vec![];
1847 for i in 0..20 {
1848 preview.push(S3Object {
1849 key: format!("folder1/file{}.txt", i),
1850 is_prefix: false,
1851 size: 100,
1852 last_modified: String::new(),
1853 storage_class: String::new(),
1854 });
1855 }
1856 state.prefix_preview.insert("folder1/".to_string(), preview);
1857
1858 state.object_visible_rows.set(10);
1859 state.object_scroll_offset = 10; state.selected_object = 15; state.selected_object = 0;
1864
1865 let visible_rows = state.object_visible_rows.get();
1867 if state.selected_object < state.object_scroll_offset {
1868 state.object_scroll_offset = state.selected_object;
1869 } else if state.selected_object >= state.object_scroll_offset + visible_rows {
1870 state.object_scroll_offset = state.selected_object.saturating_sub(visible_rows - 1);
1871 }
1872
1873 assert_eq!(state.object_scroll_offset, 0);
1875 assert!(state.selected_object >= state.object_scroll_offset);
1876 assert!(state.selected_object < state.object_scroll_offset + visible_rows);
1877 }
1878
1879 #[test]
1880 fn test_selection_below_viewport_becomes_visible() {
1881 let mut state = State::new();
1882 state.bucket_visible_rows.set(10);
1883 state.bucket_scroll_offset = 0; state.selected_row = 0;
1885
1886 state.selected_row = 20;
1888
1889 let visible_rows = state.bucket_visible_rows.get();
1891 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1892 state.bucket_scroll_offset = state.selected_row.saturating_sub(visible_rows - 1);
1893 }
1894
1895 assert_eq!(state.bucket_scroll_offset, 11); assert!(state.selected_row >= state.bucket_scroll_offset);
1898 assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1899 }
1900
1901 #[test]
1902 fn test_scroll_keeps_selection_visible_when_navigating_down() {
1903 let mut state = State::new();
1904 state.buckets.items = vec![];
1905 for i in 0..50 {
1906 state.buckets.items.push(S3Bucket {
1907 name: format!("bucket{}", i),
1908 region: "us-east-1".to_string(),
1909 creation_date: String::new(),
1910 });
1911 }
1912
1913 state.bucket_visible_rows.set(10);
1914 state.bucket_scroll_offset = 0;
1915 state.selected_row = 0;
1916
1917 for _ in 0..25 {
1919 let total_rows = state.buckets.items.len();
1920 state.selected_row = (state.selected_row + 1).min(total_rows - 1);
1921
1922 let visible_rows = state.bucket_visible_rows.get();
1924 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1925 state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1926 }
1927 }
1928
1929 assert_eq!(state.selected_row, 25);
1931 assert_eq!(state.bucket_scroll_offset, 16);
1933 assert!(state.selected_row >= state.bucket_scroll_offset);
1935 assert!(state.selected_row < state.bucket_scroll_offset + state.bucket_visible_rows.get());
1936 }
1937
1938 #[test]
1939 fn test_ctrl_d_adjusts_scroll_offset() {
1940 let mut state = State::new();
1941 state.buckets.items = vec![];
1942 for i in 0..50 {
1943 state.buckets.items.push(S3Bucket {
1944 name: format!("bucket{}", i),
1945 region: "us-east-1".to_string(),
1946 creation_date: String::new(),
1947 });
1948 }
1949
1950 state.bucket_visible_rows.set(10);
1951 state.bucket_scroll_offset = 0;
1952 state.selected_row = 5;
1953
1954 let total_rows = state.buckets.items.len();
1956 state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
1957
1958 let visible_rows = state.bucket_visible_rows.get();
1960 if state.selected_row >= state.bucket_scroll_offset + visible_rows {
1961 state.bucket_scroll_offset = state.selected_row - visible_rows + 1;
1962 }
1963
1964 assert_eq!(state.selected_row, 15);
1966 assert_eq!(state.bucket_scroll_offset, 6);
1968 assert!(state.selected_row >= state.bucket_scroll_offset);
1970 assert!(state.selected_row < state.bucket_scroll_offset + visible_rows);
1971 }
1972
1973 #[test]
1974 fn test_ctrl_u_adjusts_scroll_offset() {
1975 let mut state = State::new();
1976 state.buckets.items = vec![];
1977 for i in 0..50 {
1978 state.buckets.items.push(S3Bucket {
1979 name: format!("bucket{}", i),
1980 region: "us-east-1".to_string(),
1981 creation_date: String::new(),
1982 });
1983 }
1984
1985 state.bucket_visible_rows.set(10);
1986 state.bucket_scroll_offset = 10;
1987 state.selected_row = 15;
1988
1989 state.selected_row = state.selected_row.saturating_sub(10);
1991
1992 if state.selected_row < state.bucket_scroll_offset {
1994 state.bucket_scroll_offset = state.selected_row;
1995 }
1996
1997 assert_eq!(state.selected_row, 5);
1999 assert_eq!(state.bucket_scroll_offset, 5);
2001 assert!(state.selected_row >= state.bucket_scroll_offset);
2003 }
2004
2005 #[test]
2006 fn test_ctrl_d_clamps_to_max_rows() {
2007 let mut state = State::new();
2008 state.buckets.items = vec![];
2009 for i in 0..20 {
2010 state.buckets.items.push(S3Bucket {
2011 name: format!("bucket{}", i),
2012 region: "us-east-1".to_string(),
2013 creation_date: String::new(),
2014 });
2015 }
2016
2017 state.bucket_visible_rows.set(10);
2018 state.bucket_scroll_offset = 5;
2019 state.selected_row = 15;
2020
2021 let total_rows = state.buckets.items.len();
2023 state.selected_row = state.selected_row.saturating_add(10).min(total_rows - 1);
2024
2025 assert_eq!(state.selected_row, 19);
2027 }
2028
2029 #[test]
2030 fn test_rounded_block_with_custom_border_style() {
2031 use ratatui::prelude::Rect;
2032 let block = rounded_block()
2033 .title(format_title("Properties"))
2034 .border_style(active_border());
2035 let area = Rect::new(0, 0, 70, 12);
2036 let inner = block.inner(area);
2037 assert_eq!(inner.width, 68);
2038 assert_eq!(inner.height, 10);
2039 }
2040
2041 #[test]
2042 fn test_column_width_accounts_for_header_text() {
2043 let header_name = "Name";
2045 let header_region = "Region";
2046 let header_date = "Creation date";
2047
2048 assert!(header_name.len() >= 4);
2050
2051 let region_with_sep = header_region.len() + 2;
2053 let date_with_sep = header_date.len() + 2;
2054
2055 assert!(region_with_sep >= header_region.len());
2056 assert!(date_with_sep >= header_date.len());
2057 }
2058
2059 #[test]
2060 fn test_title_formatting_no_double_dash() {
2061 let title = format!("{} ({})", "Directory buckets", 0);
2063 let formatted = format_title(&title);
2064
2065 assert_eq!(formatted, "─ Directory buckets (0) ─");
2067 }
2068
2069 #[test]
2070 fn test_collapse_moves_to_parent() {
2071 let mut state = State::new();
2073 state.buckets.items = vec![S3Bucket {
2074 name: "bucket1".to_string(),
2075 region: "us-east-1".to_string(),
2076 creation_date: String::new(),
2077 }];
2078
2079 state.expanded_prefixes.insert("bucket1".to_string());
2081 state.bucket_preview.insert(
2082 "bucket1".to_string(),
2083 vec![S3Object {
2084 key: "folder1/".to_string(),
2085 is_prefix: true,
2086 size: 0,
2087 last_modified: String::new(),
2088 storage_class: String::new(),
2089 }],
2090 );
2091
2092 state.expanded_prefixes.insert("folder1/".to_string());
2094 state.prefix_preview.insert(
2095 "folder1/".to_string(),
2096 vec![S3Object {
2097 key: "folder1/file.txt".to_string(),
2098 is_prefix: false,
2099 size: 100,
2100 last_modified: String::new(),
2101 storage_class: String::new(),
2102 }],
2103 );
2104
2105 state.selected_row = 1;
2107
2108 assert!(state.expanded_prefixes.contains("folder1/"));
2110
2111 }
2114
2115 #[test]
2116 fn test_hierarchy_collapse_sequence() {
2117 let mut state = State::new();
2119 state.buckets.items = vec![S3Bucket {
2120 name: "bucket1".to_string(),
2121 region: "us-east-1".to_string(),
2122 creation_date: String::new(),
2123 }];
2124
2125 state.expanded_prefixes.insert("bucket1".to_string());
2127 state.bucket_preview.insert(
2128 "bucket1".to_string(),
2129 vec![S3Object {
2130 key: "level1/".to_string(),
2131 is_prefix: true,
2132 size: 0,
2133 last_modified: String::new(),
2134 storage_class: String::new(),
2135 }],
2136 );
2137
2138 state.expanded_prefixes.insert("level1/".to_string());
2139 state.prefix_preview.insert(
2140 "level1/".to_string(),
2141 vec![S3Object {
2142 key: "level1/level2/".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/level2/".to_string());
2151 state.prefix_preview.insert(
2152 "level1/level2/".to_string(),
2153 vec![S3Object {
2154 key: "level1/level2/file.txt".to_string(),
2155 is_prefix: false,
2156 size: 100,
2157 last_modified: String::new(),
2158 storage_class: String::new(),
2159 }],
2160 );
2161
2162 assert_eq!(state.expanded_prefixes.len(), 3);
2164
2165 }
2168
2169 #[test]
2170 fn test_objects_collapse_jumps_to_parent() {
2171 let mut state = State::new();
2173 state.current_bucket = Some("test-bucket".to_string());
2174
2175 state.objects = vec![S3Object {
2177 key: "folder1/".to_string(),
2178 is_prefix: true,
2179 size: 0,
2180 last_modified: String::new(),
2181 storage_class: String::new(),
2182 }];
2183
2184 state.expanded_prefixes.insert("folder1/".to_string());
2185 state.prefix_preview.insert(
2186 "folder1/".to_string(),
2187 vec![S3Object {
2188 key: "folder1/folder2/".to_string(),
2189 is_prefix: true,
2190 size: 0,
2191 last_modified: String::new(),
2192 storage_class: String::new(),
2193 }],
2194 );
2195
2196 state
2197 .expanded_prefixes
2198 .insert("folder1/folder2/".to_string());
2199 state.prefix_preview.insert(
2200 "folder1/folder2/".to_string(),
2201 vec![S3Object {
2202 key: "folder1/folder2/file.txt".to_string(),
2203 is_prefix: false,
2204 size: 100,
2205 last_modified: String::new(),
2206 storage_class: String::new(),
2207 }],
2208 );
2209
2210 state.selected_object = 1;
2212
2213 assert!(state.expanded_prefixes.contains("folder1/folder2/"));
2215
2216 }
2220
2221 #[test]
2222 fn test_bucket_shows_collapsed_cursor_while_loading_no_preview() {
2223 let mut state = State::new();
2226 state.buckets.items = vec![S3Bucket {
2227 name: "my-bucket".to_string(),
2228 region: String::new(),
2229 creation_date: String::new(),
2230 }];
2231
2232 state.expanded_prefixes.insert("my-bucket".to_string());
2234
2235 let preview_loaded = state.bucket_preview.contains_key("my-bucket");
2237 let is_expanded = state.expanded_prefixes.contains("my-bucket") && preview_loaded;
2238 assert!(
2239 !is_expanded,
2240 "Cursor must be ▶ (collapsed) while preview is still loading"
2241 );
2242
2243 state.bucket_preview.insert("my-bucket".to_string(), vec![]);
2245 let preview_loaded = state.bucket_preview.contains_key("my-bucket");
2246 let is_expanded = state.expanded_prefixes.contains("my-bucket") && preview_loaded;
2247 assert!(
2248 is_expanded,
2249 "Cursor must be ▼ (expanded) once preview is loaded"
2250 );
2251 }
2252}