1use crate::app::{App, Service};
3use crate::common::{render_pagination_text, InputFocus, PageSize, SortDirection};
4use crate::inventory::{self, InventoryInput};
5use crate::keymap::{Action, Mode};
6use crate::preferences::{PreferenceColumn, Preferences, PAGE_SIZE_OPTIONS};
7use crate::table::{TableState, TableWindow};
8use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
9use crate::ui::table::{expanded_from_columns, render_table, Column, TableConfig};
10use crate::ui::{format_title, titled_block};
11use ratatui::{prelude::*, widgets::Paragraph};
12
13pub const COLUMN_NAMES: [&str; 2] = ["Key", "Value"];
14const SMALL_PAGE_SIZES: &[(PageSize, &str)] = &[
15 (PageSize::Ten, "10"),
16 (PageSize::TwentyFive, "25"),
17 (PageSize::Fifty, "50"),
18];
19
20pub trait Tag {
21 fn key(&self) -> &str;
22 fn value(&self) -> &str;
23}
24impl Tag for (String, String) {
25 fn key(&self) -> &str {
26 &self.0
27 }
28 fn value(&self) -> &str {
29 &self.1
30 }
31}
32macro_rules! impl_tag {
33 ($t:ty) => {
34 impl Tag for $t {
35 fn key(&self) -> &str {
36 &self.key
37 }
38 fn value(&self) -> &str {
39 &self.value
40 }
41 }
42 };
43}
44impl_tag!(crate::iam::RoleTag);
45impl_tag!(crate::iam::UserTag);
46impl_tag!(crate::ec2::tag::InstanceTag);
47
48#[derive(Clone, Copy)]
49pub enum TagSort {
50 Source,
51 Key,
52 Value,
53}
54
55pub fn filtered<'a, T: Tag>(items: &'a [T], filter: &str, sort: TagSort) -> Vec<&'a T> {
56 let query = filter.to_lowercase();
57 let mut rows: Vec<_> = items
58 .iter()
59 .filter(|t| {
60 query.is_empty()
61 || t.key().to_lowercase().contains(&query)
62 || t.value().to_lowercase().contains(&query)
63 })
64 .collect();
65 match sort {
66 TagSort::Source => {}
67 TagSort::Key => rows.sort_by(|a, b| a.key().cmp(b.key())),
68 TagSort::Value => rows.sort_by(|a, b| a.value().cmp(b.value())),
69 }
70 rows
71}
72
73pub enum TagColumn {
74 Key,
75 Value,
76}
77impl<T: Tag> Column<T> for TagColumn {
78 fn name(&self) -> &str {
79 match self {
80 Self::Key => COLUMN_NAMES[0],
81 Self::Value => COLUMN_NAMES[1],
82 }
83 }
84 fn width(&self) -> u16 {
85 match self {
86 Self::Key => 30,
87 Self::Value => 50,
88 }
89 }
90 fn render(&self, tag: &T) -> (String, Style) {
91 (
92 match self {
93 Self::Key => tag.key(),
94 Self::Value => tag.value(),
95 }
96 .into(),
97 Style::default(),
98 )
99 }
100}
101pub fn columns<T: Tag + 'static>() -> Vec<Box<dyn Column<T>>> {
102 vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)]
103}
104
105pub struct TagsConfig<'a, T> {
106 pub table: &'a TableState<T>,
107 pub items: &'a [T],
108 pub columns: Vec<Box<dyn Column<T>>>,
109 pub sort: TagSort,
110 pub window: TableWindow,
111 pub focus: InputFocus,
112 pub mode: Mode,
113 pub loading: bool,
114}
115
116pub fn render<T: Tag>(frame: &mut Frame, area: Rect, config: TagsConfig<'_, T>) {
117 let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area);
118 let view = config.table.prepare_view(
119 filtered(config.items, &config.table.filter, config.sort),
120 config.window,
121 );
122 let pagination = render_pagination_text(view.current_page, view.total_pages);
123 render_simple_filter(
124 frame,
125 chunks[0],
126 SimpleFilterConfig {
127 filter_text: &config.table.filter,
128 placeholder: "Search tags",
129 pagination: &pagination,
130 mode: config.mode,
131 is_input_focused: config.focus == InputFocus::Filter,
132 is_pagination_focused: config.focus == InputFocus::Pagination,
133 },
134 );
135 if config.loading {
136 frame.render_widget(
137 Paragraph::new("Loading tags...")
138 .block(titled_block("Tags"))
139 .alignment(Alignment::Center),
140 chunks[1],
141 );
142 return;
143 }
144 let sort_column = match config.sort {
145 TagSort::Source => "",
146 TagSort::Key => "Key",
147 TagSort::Value => "Value",
148 };
149 render_table(
150 frame,
151 TableConfig {
152 items: view.items,
153 selected_index: view.selected_index,
154 expanded_index: view.expanded_index,
155 columns: &config.columns,
156 sort_column,
157 sort_direction: SortDirection::Asc,
158 title: format_title(&format!("Tags ({})", view.total_items)),
159 area: chunks[1],
160 get_expanded_content: Some(Box::new(|tag| expanded_from_columns(&config.columns, tag))),
161 is_active: config.mode == Mode::Normal,
162 },
163 );
164}
165
166pub fn fixed_columns() -> Vec<PreferenceColumn> {
167 COLUMN_NAMES
168 .into_iter()
169 .map(|name| PreferenceColumn {
170 id: name.into(),
171 label: name.into(),
172 visible: true,
173 locked: true,
174 })
175 .collect()
176}
177pub fn preferences(columns: Vec<PreferenceColumn>, size: PageSize, small: bool) -> Preferences {
178 Preferences::table_with_page_sizes(
179 columns,
180 size,
181 if small {
182 SMALL_PAGE_SIZES
183 } else {
184 PAGE_SIZE_OPTIONS
185 },
186 )
187}
188
189#[derive(Clone, Copy)]
190pub(crate) enum Source {
191 Efs,
192 Fsx,
193 DynamoDb,
194 Ec2,
195 IamUser,
196 IamRole,
197}
198impl Source {
199 pub(crate) fn active(app: &App) -> Option<Self> {
200 use crate::ui::{dynamodb, ec2, efs, fsx, iam};
201 match app.current_service {
202 Service::EfsFileSystems
203 if app.efs_state.current_file_system.is_some()
204 && app.efs_state.detail_tab == efs::DetailTab::Tags =>
205 {
206 Some(Self::Efs)
207 }
208 Service::FsxFileSystems
209 if app.fsx_state.current_file_system.is_some()
210 && app.fsx_state.detail_tab == fsx::DetailTab::Tags =>
211 {
212 Some(Self::Fsx)
213 }
214 Service::DynamoDbTables
215 if app.dynamodb_state.current_table.is_some()
216 && app.dynamodb_state.detail_tab == dynamodb::DetailTab::Tags =>
217 {
218 Some(Self::DynamoDb)
219 }
220 Service::Ec2Instances
221 if app.ec2_state.current_instance.is_some()
222 && app.ec2_state.detail_tab == ec2::DetailTab::Tags =>
223 {
224 Some(Self::Ec2)
225 }
226 Service::IamUsers
227 if app.iam_state.current_user.is_some()
228 && app.iam_state.user_tab == iam::UserTab::Tags =>
229 {
230 Some(Self::IamUser)
231 }
232 Service::IamRoles
233 if app.iam_state.current_role.is_some()
234 && app.iam_state.role_tab == iam::RoleTab::Tags =>
235 {
236 Some(Self::IamRole)
237 }
238 _ => None,
239 }
240 }
241 fn count(self, app: &App) -> usize {
242 match self {
243 Self::Efs => app
244 .efs_state
245 .file_systems
246 .items
247 .iter()
248 .find(|fs| Some(&fs.file_system_id) == app.efs_state.current_file_system.as_ref())
249 .map(|fs| filtered(&fs.tags, &app.efs_state.tags_table.filter, TagSort::Key).len())
250 .unwrap_or(0),
251 Self::Fsx => app
252 .fsx_state
253 .file_systems
254 .items
255 .iter()
256 .find(|fs| Some(&fs.file_system_id) == app.fsx_state.current_file_system.as_ref())
257 .map(|fs| filtered(&fs.tags, &app.fsx_state.tags_table.filter, TagSort::Key).len())
258 .unwrap_or(0),
259 Self::DynamoDb => filtered(
260 &app.dynamodb_state.tags_table.items,
261 &app.dynamodb_state.tags_table.filter,
262 TagSort::Key,
263 )
264 .len(),
265 Self::Ec2 => filtered(
266 &app.ec2_state.tags.items,
267 &app.ec2_state.tags.filter,
268 TagSort::Value,
269 )
270 .len(),
271 Self::IamUser => filtered(
272 &app.iam_state.user_tags.items,
273 &app.iam_state.user_tags.filter,
274 TagSort::Source,
275 )
276 .len(),
277 Self::IamRole => filtered(
278 &app.iam_state.tags.items,
279 &app.iam_state.tags.filter,
280 TagSort::Source,
281 )
282 .len(),
283 }
284 }
285}
286
287pub fn active_filter_mut(app: &mut App) -> Option<&mut String> {
288 Some(match Source::active(app)? {
289 Source::Efs => &mut app.efs_state.tags_table.filter,
290 Source::Fsx => &mut app.fsx_state.tags_table.filter,
291 Source::DynamoDb => &mut app.dynamodb_state.tags_table.filter,
292 Source::Ec2 => &mut app.ec2_state.tags.filter,
293 Source::IamUser => &mut app.iam_state.user_tags.filter,
294 Source::IamRole => &mut app.iam_state.tags.filter,
295 })
296}
297
298pub fn reset_active(app: &mut App) -> bool {
299 match Source::active(app) {
300 Some(Source::Efs) => app.efs_state.tags_table.reset(),
301 Some(Source::Fsx) => app.fsx_state.tags_table.reset(),
302 Some(Source::DynamoDb) => app.dynamodb_state.tags_table.reset(),
303 Some(Source::Ec2) => app.ec2_state.tags.reset(),
304 Some(Source::IamUser) => app.iam_state.user_tags.reset(),
305 Some(Source::IamRole) => app.iam_state.tags.reset(),
306 None => return false,
307 }
308 true
309}
310
311pub fn handle_action(app: &mut App, action: &Action) -> bool {
312 if !app.service_selected {
313 return false;
314 }
315 let Some(source) = Source::active(app) else {
316 return false;
317 };
318 if !matches!(app.mode, Mode::Normal | Mode::FilterInput)
320 || matches!(
321 action,
322 Action::GoBack
323 | Action::Refresh
324 | Action::Yank
325 | Action::NextDetailTab
326 | Action::PrevDetailTab
327 | Action::ToggleExactMatch
328 | Action::ToggleShowExpired
329 | Action::CycleSortColumn
330 | Action::ToggleSortDirection
331 )
332 {
333 return false;
334 }
335 let count = source.count(app);
336 macro_rules! dispatch {
337 ($table:expr, $focus:expr) => {{
338 if app.mode == Mode::Normal
339 && !app.page_input.is_empty()
340 && matches!(action, Action::ApplyFilter | Action::OpenColumnSelector)
341 {
342 if let Ok(page) = app.page_input.parse() {
343 inventory::go_to_page(&mut $table, page, count);
344 }
345 app.page_input.clear();
346 return true;
347 }
348 inventory::handle_action(
349 InventoryInput {
350 table: &mut $table,
351 focus: &mut $focus,
352 mode: &mut app.mode,
353 page_input: &mut app.page_input,
354 error_message: &mut app.error_message,
355 error_scroll: &mut app.error_scroll,
356 filtered_count: count,
357 selected_arn: None,
358 clipboard: &app.clipboard,
359 },
360 action,
361 )
362 }};
363 }
364 match source {
365 Source::Efs => dispatch!(app.efs_state.tags_table, app.efs_state.input_focus),
366 Source::Fsx => dispatch!(app.fsx_state.tags_table, app.fsx_state.input_focus),
367 Source::DynamoDb => dispatch!(
368 app.dynamodb_state.tags_table,
369 app.dynamodb_state.input_focus
370 ),
371 Source::Ec2 => dispatch!(app.ec2_state.tags, app.ec2_state.input_focus),
372 Source::IamUser => dispatch!(app.iam_state.user_tags, app.iam_state.user_input_focus),
373 Source::IamRole => dispatch!(app.iam_state.tags, app.iam_state.role_input_focus),
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use crate::preferences::{bindings, PreferenceRow};
381 use ratatui::{backend::TestBackend, Terminal};
382
383 fn rows() -> Vec<(String, String)> {
384 (0..25)
385 .rev()
386 .map(|i| (format!("key{i:02}"), format!("value{i:02}")))
387 .collect()
388 }
389
390 #[test]
391 fn tags_filter_key_or_value_and_sort_before_preparing_pages() {
392 let rows = rows();
393 assert_eq!(filtered(&rows, "KEY12", TagSort::Key)[0].1, "value12");
394 assert_eq!(filtered(&rows, "VALUE12", TagSort::Value)[0].0, "key12");
395 let typed = [crate::iam::RoleTag {
396 key: "環境".into(),
397 value: "PRODUCTION".into(),
398 }];
399 assert_eq!(filtered(&typed, "production", TagSort::Source).len(), 1);
400 let state = TableState {
401 page_size: PageSize::Ten,
402 selected: 12,
403 expanded_item: Some(11),
404 ..TableState::new()
405 };
406 let view = state.prepare_view(
407 filtered(&rows, "key", TagSort::Key),
408 TableWindow::SelectedPage,
409 );
410 assert_eq!(view.items[0].0, "key10");
411 assert_eq!(view.items.len(), 10);
412 assert_eq!(view.selected_index, 2);
413 assert_eq!(view.expanded_index, Some(1));
414 }
415
416 #[test]
417 fn tags_renderer_applies_page_size_and_expansion_to_tuple_rows() {
418 let rows = rows();
419 let state = TableState {
420 page_size: PageSize::Ten,
421 selected: 12,
422 expanded_item: Some(11),
423 ..TableState::new()
424 };
425 let mut terminal = Terminal::new(TestBackend::new(160, 30)).unwrap();
426 terminal
427 .draw(|frame| {
428 render(
429 frame,
430 frame.area(),
431 TagsConfig {
432 table: &state,
433 items: &rows,
434 columns: columns(),
435 sort: TagSort::Key,
436 window: TableWindow::SelectedPage,
437 focus: InputFocus::Filter,
438 mode: Mode::Normal,
439 loading: false,
440 },
441 )
442 })
443 .unwrap();
444 let text: String = terminal
445 .backend()
446 .buffer()
447 .content()
448 .iter()
449 .map(|c| c.symbol())
450 .collect();
451 assert!(text.contains("Tags (25)"));
452 assert!(text.contains("1 [2] 3"));
453 assert!(text.contains("► key12"));
454 assert!(text.contains("▼ key11"));
455 assert!(text.contains("Value: value11"));
456 assert!(!text.contains("key09"));
457 assert!(!text.contains("key20"));
458 }
459
460 #[test]
461 fn tags_actions_do_not_change_parent_inventory_and_preferences_match_page_options() {
462 let mut app = App::new_without_client("test".into(), None);
463 app.current_service = Service::FsxFileSystems;
464 app.service_selected = true;
465 app.mode = Mode::Normal;
466 app.fsx_state.current_file_system = Some("fs".into());
467 app.fsx_state.detail_tab = crate::ui::fsx::DetailTab::Tags;
468 app.fsx_state.file_systems.items = vec![rusticity_core::fsx::FsxFileSystem {
469 file_system_id: "fs".into(),
470 tags: rows(),
471 ..Default::default()
472 }];
473 app.fsx_state.tags_table.page_size = PageSize::Ten;
474 app.handle_action(Action::PageDown);
475 assert_eq!(app.fsx_state.tags_table.selected, 10);
476 assert_eq!(app.fsx_state.file_systems.selected, 0);
477 app.handle_action(Action::StartFilter);
478 for c in "VALUE12".chars() {
479 app.handle_action(Action::FilterInput(c));
480 }
481 assert_eq!(Source::active(&app).unwrap().count(&app), 1);
482 assert_eq!(app.fsx_state.tags_table.selected, 0);
483 assert!(app.fsx_state.file_systems.filter.is_empty());
484 app.handle_action(Action::ApplyFilter);
485 app.handle_action(Action::Select);
486 assert_eq!(app.fsx_state.tags_table.expanded_item, Some(0));
487 app.handle_action(Action::OpenColumnSelector);
488 let model = bindings::active(&app).unwrap();
489 assert_eq!(model.column_count(), 2);
490 assert_eq!(
491 model
492 .rows()
493 .iter()
494 .filter(|r| matches!(r, PreferenceRow::PageSize { .. }))
495 .count(),
496 4
497 );
498 app.current_service = Service::IamRoles;
499 app.iam_state.current_role = Some("role".into());
500 app.iam_state.role_tab = crate::ui::iam::RoleTab::Tags;
501 let model = bindings::active(&app).unwrap();
502 assert_eq!(
503 model
504 .rows()
505 .iter()
506 .filter(|r| matches!(r, PreferenceRow::PageSize { .. }))
507 .count(),
508 3
509 );
510 assert_eq!(model.max_index(), 7);
511 }
512
513 #[test]
514 fn tags_ec2_preferences_keep_custom_column_toggles() {
515 let mut app = App::new_without_client("test".into(), None);
516 app.current_service = Service::Ec2Instances;
517 app.service_selected = true;
518 app.ec2_state.current_instance = Some("i-test".into());
519 app.ec2_state.detail_tab = crate::ui::ec2::DetailTab::Tags;
520 app.mode = Mode::ColumnSelector;
521 let before = app.ec2_state.tag_visible_column_ids.clone();
522 let inventory = app.ec2_visible_column_ids.clone();
523 app.column_selector_index = 2;
524 app.handle_action(Action::ToggleColumn);
525 assert_eq!(app.ec2_state.tag_visible_column_ids.len(), before.len() - 1);
526 app.column_selector_index = 1;
527 app.handle_action(Action::ToggleColumn);
528 assert_eq!(app.ec2_state.tag_visible_column_ids.len(), 1);
529 assert_eq!(app.ec2_visible_column_ids, inventory);
530 }
531}