1use crate::common::translate_column;
2use crate::common::{format_bytes, ColumnId, UTC_TIMESTAMP_WIDTH};
3use crate::ui::table::Column as TableColumn;
4use ratatui::prelude::*;
5use std::collections::HashMap;
6
7pub fn init(i18n: &mut HashMap<String, String>) {
8 for col in [
9 BucketColumn::Name,
10 BucketColumn::Region,
11 BucketColumn::CreationDate,
12 ] {
13 i18n.entry(col.id().to_string())
14 .or_insert_with(|| col.default_name().to_string());
15 }
16
17 for col in [
18 ObjectColumn::Key,
19 ObjectColumn::Size,
20 ObjectColumn::LastModified,
21 ObjectColumn::StorageClass,
22 ] {
23 i18n.entry(col.id().to_string())
24 .or_insert_with(|| col.default_name().to_string());
25 }
26}
27
28pub fn console_url_buckets(region: &str) -> String {
29 format!(
30 "https://{}.console.aws.amazon.com/s3/buckets?region={}",
31 region, region
32 )
33}
34
35pub fn console_url_bucket(region: &str, bucket: &str, prefix: &str) -> String {
36 if prefix.is_empty() {
37 format!(
38 "https://s3.console.aws.amazon.com/s3/buckets/{}?region={}",
39 bucket, region
40 )
41 } else {
42 format!(
43 "https://s3.console.aws.amazon.com/s3/buckets/{}?region={}&prefix={}",
44 bucket,
45 region,
46 urlencoding::encode(prefix)
47 )
48 }
49}
50
51#[derive(Debug, Clone)]
52pub struct Bucket {
53 pub name: String,
54 pub region: String,
55 pub creation_date: String,
56}
57
58#[derive(Debug, Clone)]
59pub struct Object {
60 pub key: String,
61 pub size: i64,
62 pub last_modified: String,
63 pub is_prefix: bool,
64 pub storage_class: String,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub enum BucketColumn {
69 Name,
70 Region,
71 CreationDate,
72}
73
74impl BucketColumn {
75 pub fn id(&self) -> &'static str {
76 match self {
77 BucketColumn::Name => "column.s3.bucket.name",
78 BucketColumn::Region => "column.s3.bucket.region",
79 BucketColumn::CreationDate => "column.s3.bucket.creation_date",
80 }
81 }
82
83 pub fn default_name(&self) -> &'static str {
84 match self {
85 BucketColumn::Name => "Name",
86 BucketColumn::Region => "Region",
87 BucketColumn::CreationDate => "Creation date",
88 }
89 }
90
91 pub fn all() -> [BucketColumn; 3] {
92 [
93 BucketColumn::Name,
94 BucketColumn::Region,
95 BucketColumn::CreationDate,
96 ]
97 }
98
99 pub fn ids() -> Vec<ColumnId> {
100 Self::all().iter().map(|c| c.id()).collect()
101 }
102
103 pub fn from_id(id: &str) -> Option<Self> {
104 match id {
105 "column.s3.bucket.name" => Some(BucketColumn::Name),
106 "column.s3.bucket.region" => Some(BucketColumn::Region),
107 "column.s3.bucket.creation_date" => Some(BucketColumn::CreationDate),
108 _ => None,
109 }
110 }
111
112 pub fn name(&self) -> String {
113 translate_column(self.id(), self.default_name())
114 }
115}
116
117impl TableColumn<Bucket> for BucketColumn {
118 fn name(&self) -> &str {
119 Box::leak(translate_column(self.id(), self.default_name()).into_boxed_str())
120 }
121
122 fn width(&self) -> u16 {
123 let translated = translate_column(self.id(), self.default_name());
124 translated.len().max(match self {
125 BucketColumn::Name => 30,
126 BucketColumn::Region => 15,
127 BucketColumn::CreationDate => UTC_TIMESTAMP_WIDTH as usize,
128 }) as u16
129 }
130
131 fn render(&self, item: &Bucket) -> (String, Style) {
132 let text = match self {
133 BucketColumn::Name => item.name.clone(),
134 BucketColumn::Region => item.region.clone(),
135 BucketColumn::CreationDate => item.creation_date.clone(),
136 };
137 (text, Style::default())
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq)]
142pub enum ObjectColumn {
143 Key,
144 Type,
145 LastModified,
146 Size,
147 StorageClass,
148}
149
150impl ObjectColumn {
151 pub fn id(&self) -> &'static str {
152 match self {
153 ObjectColumn::Key => "column.s3.object.key",
154 ObjectColumn::Type => "column.s3.object.type",
155 ObjectColumn::LastModified => "column.s3.object.last_modified",
156 ObjectColumn::Size => "column.s3.object.size",
157 ObjectColumn::StorageClass => "column.s3.object.storage_class",
158 }
159 }
160
161 pub fn default_name(&self) -> &'static str {
162 match self {
163 ObjectColumn::Key => "Name",
164 ObjectColumn::Type => "Type",
165 ObjectColumn::LastModified => "Last modified",
166 ObjectColumn::Size => "Size",
167 ObjectColumn::StorageClass => "Storage class",
168 }
169 }
170
171 pub fn all() -> [ObjectColumn; 5] {
172 [
173 ObjectColumn::Key,
174 ObjectColumn::Type,
175 ObjectColumn::LastModified,
176 ObjectColumn::Size,
177 ObjectColumn::StorageClass,
178 ]
179 }
180
181 pub fn from_id(id: &str) -> Option<Self> {
182 match id {
183 "column.s3.object.key" => Some(ObjectColumn::Key),
184 "column.s3.object.type" => Some(ObjectColumn::Type),
185 "column.s3.object.last_modified" => Some(ObjectColumn::LastModified),
186 "column.s3.object.size" => Some(ObjectColumn::Size),
187 "column.s3.object.storage_class" => Some(ObjectColumn::StorageClass),
188 _ => None,
189 }
190 }
191
192 pub fn name(&self) -> String {
193 translate_column(self.id(), self.default_name())
194 }
195}
196
197impl TableColumn<Object> for ObjectColumn {
198 fn name(&self) -> &str {
199 Box::leak(translate_column(self.id(), self.default_name()).into_boxed_str())
200 }
201
202 fn width(&self) -> u16 {
203 let translated = translate_column(self.id(), self.default_name());
204 translated.len().max(match self {
205 ObjectColumn::Key => 40,
206 ObjectColumn::Type => 10,
207 ObjectColumn::LastModified => UTC_TIMESTAMP_WIDTH as usize,
208 ObjectColumn::Size => 15,
209 ObjectColumn::StorageClass => 15,
210 }) as u16
211 }
212
213 fn render(&self, item: &Object) -> (String, Style) {
214 let text = match self {
215 ObjectColumn::Key => {
216 let icon = if item.is_prefix { "📁" } else { "📄" };
217 format!("{} {}", icon, item.key)
218 }
219 ObjectColumn::Type => {
220 if item.is_prefix {
221 "Folder".to_string()
222 } else {
223 "File".to_string()
224 }
225 }
226 ObjectColumn::LastModified => {
227 if item.last_modified.is_empty() {
228 String::new()
229 } else {
230 format!(
231 "{} (UTC)",
232 item.last_modified
233 .split('T')
234 .next()
235 .unwrap_or(&item.last_modified)
236 )
237 }
238 }
239 ObjectColumn::Size => {
240 if item.is_prefix {
241 String::new()
242 } else {
243 format_bytes(item.size)
244 }
245 }
246 ObjectColumn::StorageClass => {
247 if item.storage_class.is_empty() {
248 String::new()
249 } else {
250 item.storage_class
251 .chars()
252 .next()
253 .unwrap()
254 .to_uppercase()
255 .to_string()
256 + &item.storage_class[1..].to_lowercase()
257 }
258 }
259 };
260 (text, Style::default())
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn test_bucket_column_ids_have_correct_prefix() {
270 for col in BucketColumn::all() {
271 assert!(
272 col.id().starts_with("column.s3.bucket."),
273 "BucketColumn ID '{}' should start with 'column.s3.bucket.'",
274 col.id()
275 );
276 }
277 }
278
279 #[test]
280 fn test_object_column_ids_have_correct_prefix() {
281 for col in ObjectColumn::all() {
282 assert!(
283 col.id().starts_with("column.s3.object."),
284 "ObjectColumn ID '{}' should start with 'column.s3.object.'",
285 col.id()
286 );
287 }
288 }
289}