Skip to main content

rusticity_term/ui/
ecr.rs

1use crate::app::App;
2use crate::common::{
3    filter_by_field, render_pagination_text, CyclicEnum, InputFocus, SortDirection,
4};
5use crate::ecr::image::{self, Image as EcrImage};
6use crate::ecr::repo::{self, Repository as EcrRepository};
7use crate::keymap::Mode;
8use crate::table::{TableState, TableWindow};
9use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
10use crate::ui::table::{expanded_from_columns, render_table, Column, TableConfig};
11use crate::ui::{format_title, render_tabs};
12use ratatui::{prelude::*, widgets::*};
13
14pub const FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
15
16pub struct State {
17    pub repositories: TableState<EcrRepository>,
18    pub tab: Tab,
19    pub current_repository: Option<String>,
20    pub current_repository_uri: Option<String>,
21    pub images: TableState<EcrImage>,
22    pub input_focus: InputFocus,
23}
24
25impl Default for State {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl State {
32    pub fn new() -> Self {
33        Self {
34            repositories: TableState::new(),
35            tab: Tab::Private,
36            current_repository: None,
37            current_repository_uri: None,
38            images: TableState::new(),
39            input_focus: InputFocus::Filter,
40        }
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub enum Tab {
46    Private,
47    Public,
48}
49
50impl CyclicEnum for Tab {
51    const ALL: &'static [Self] = &[Self::Private, Self::Public];
52}
53
54impl Tab {
55    pub fn name(&self) -> &'static str {
56        match self {
57            Tab::Private => "Private",
58            Tab::Public => "Public",
59        }
60    }
61}
62
63pub fn filtered_ecr_repositories(app: &App) -> Vec<&EcrRepository> {
64    filter_by_field(
65        &app.ecr_state.repositories.items,
66        &app.ecr_state.repositories.filter,
67        |r| &r.name,
68    )
69}
70
71pub fn filtered_ecr_images(app: &App) -> Vec<&EcrImage> {
72    if app.ecr_state.images.filter.is_empty() {
73        app.ecr_state.images.items.iter().collect()
74    } else {
75        app.ecr_state
76            .images
77            .items
78            .iter()
79            .filter(|img| {
80                img.tag
81                    .to_lowercase()
82                    .contains(&app.ecr_state.images.filter.to_lowercase())
83                    || img
84                        .digest
85                        .to_lowercase()
86                        .contains(&app.ecr_state.images.filter.to_lowercase())
87            })
88            .collect()
89    }
90}
91
92pub fn render_repositories(frame: &mut Frame, app: &App, area: Rect) {
93    frame.render_widget(Clear, area);
94
95    if app.ecr_state.current_repository.is_some() {
96        render_images(frame, app, area);
97    } else {
98        render_repository_list(frame, app, area);
99    }
100}
101
102pub fn render_repository_list(frame: &mut Frame, app: &App, area: Rect) {
103    let chunks = Layout::default()
104        .direction(Direction::Vertical)
105        .constraints([
106            Constraint::Length(1), // Tabs
107            Constraint::Length(3), // Filter
108            Constraint::Min(0),    // Table
109        ])
110        .split(area);
111
112    let tabs: Vec<(&str, Tab)> = Tab::ALL.iter().map(|tab| (tab.name(), *tab)).collect();
113    render_tabs(frame, chunks[0], &tabs, &app.ecr_state.tab);
114
115    let view = app
116        .ecr_state
117        .repositories
118        .prepare_view(filtered_ecr_repositories(app), TableWindow::SelectedPage);
119    let pagination = render_pagination_text(view.current_page, view.total_pages);
120
121    render_simple_filter(
122        frame,
123        chunks[1],
124        SimpleFilterConfig {
125            filter_text: &app.ecr_state.repositories.filter,
126            placeholder: "Search by repository substring",
127            pagination: &pagination,
128            mode: app.mode,
129            is_input_focused: app.ecr_state.input_focus == InputFocus::Filter,
130            is_pagination_focused: app.ecr_state.input_focus == InputFocus::Pagination,
131        },
132    );
133
134    let tab_label = match app.ecr_state.tab {
135        Tab::Private => "Private",
136        Tab::Public => "Public",
137    };
138    let title = format_title(&format!(
139        "{} repositories ({})",
140        tab_label, view.total_items
141    ));
142
143    let columns: Vec<Box<dyn Column<EcrRepository>>> = app
144        .ecr_repo_visible_column_ids
145        .iter()
146        .filter_map(|col_id| {
147            repo::Column::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<EcrRepository>>)
148        })
149        .collect();
150
151    let config = TableConfig {
152        items: view.items,
153        selected_index: view.selected_index,
154        expanded_index: view.expanded_index,
155        columns: &columns,
156        sort_column: "Repository name",
157        sort_direction: SortDirection::Asc,
158        title,
159        area: chunks[2],
160        get_expanded_content: Some(Box::new(|repo: &EcrRepository| {
161            expanded_from_columns(&columns, repo)
162        })),
163        is_active: app.mode != Mode::FilterInput,
164    };
165
166    render_table(frame, config);
167}
168
169pub fn render_images(frame: &mut Frame, app: &App, area: Rect) {
170    let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area);
171    let view = app
172        .ecr_state
173        .images
174        .prepare_view(filtered_ecr_images(app), TableWindow::SelectedPage);
175    let pagination = render_pagination_text(view.current_page, view.total_pages);
176    render_simple_filter(
177        frame,
178        chunks[0],
179        SimpleFilterConfig {
180            filter_text: &app.ecr_state.images.filter,
181            placeholder: "Search artifacts",
182            pagination: &pagination,
183            mode: app.mode,
184            is_input_focused: true,
185            is_pagination_focused: false,
186        },
187    );
188    let columns: Vec<Box<dyn Column<EcrImage>>> = app
189        .ecr_image_visible_column_ids
190        .iter()
191        .filter_map(|id| {
192            image::Column::from_id(id).map(|col| Box::new(col) as Box<dyn Column<EcrImage>>)
193        })
194        .collect();
195    render_table(
196        frame,
197        TableConfig {
198            items: view.items,
199            selected_index: view.selected_index,
200            expanded_index: view.expanded_index,
201            columns: &columns,
202            sort_column: "Pushed at",
203            sort_direction: SortDirection::Desc,
204            title: format_title(&format!("Images ({})", view.total_items)),
205            area: chunks[1],
206            get_expanded_content: Some(Box::new(|image| expanded_from_columns(&columns, image))),
207            is_active: app.mode != Mode::FilterInput,
208        },
209    );
210}
211
212#[cfg(test)]
213mod prepared_image_tests {
214    use super::*;
215    use crate::common::PageSize;
216    use ratatui::{backend::TestBackend, Terminal};
217
218    #[test]
219    fn images_use_their_own_page_size_and_safe_relative_selection() {
220        let mut app = App::new_without_client("test".into(), None);
221        app.ecr_state.repositories.page_size = PageSize::OneHundred;
222        app.ecr_state.images.page_size = PageSize::Ten;
223        app.ecr_state.images.items = (0..25)
224            .map(|i| EcrImage {
225                tag: format!("tag{i:02}"),
226                digest: format!("digest{i:02}"),
227                artifact_type: "Image".into(),
228                pushed_at: String::new(),
229                size_bytes: 0,
230                uri: String::new(),
231                last_pull_time: String::new(),
232            })
233            .collect();
234        app.ecr_state.images.selected = 12;
235        app.ecr_state.images.expanded_item = Some(11);
236        app.ecr_state.images.scroll_offset = 200; // Legacy scroll offsets must not underflow.
237        let mut terminal = Terminal::new(TestBackend::new(240, 40)).unwrap();
238        terminal.draw(|f| render_images(f, &app, f.area())).unwrap();
239        let text: String = terminal
240            .backend()
241            .buffer()
242            .content()
243            .iter()
244            .map(|c| c.symbol())
245            .collect();
246        assert!(text.contains("1 [2] 3"));
247        assert!(text.contains("► tag12"));
248        assert!(text.contains("▼ tag11"));
249        assert!(!text.contains("tag09"));
250        assert!(!text.contains("tag20"));
251        app.ecr_state.images.filter = "no match".into();
252        terminal.draw(|f| render_images(f, &app, f.area())).unwrap();
253    }
254}