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), Constraint::Length(3), Constraint::Min(0), ])
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; 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}