Skip to main content

rusticity_term/
tags.rs

1//! Shared key/value tag filtering, rendering, input routing and preferences.
2use 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    // Resource-level navigation, copying and reloads stay with their service.
319    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}