use clap::Parser; use clipboard::{ClipboardContext, ClipboardProvider}; use std::{ io, thread, time::{Duration, Instant}, }; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use tui::{ backend::{Backend, CrosstermBackend}, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::{Span, Spans}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, Frame, Terminal, }; mod registry; struct StatefulList { state: ListState, items: Vec, } impl StatefulList { fn with_items(items: Vec) -> StatefulList { StatefulList { state: ListState::default(), items, } } fn next(&mut self) { let i = match self.state.selected() { Some(i) => { if i >= self.items.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.state.select(Some(i)) } fn previous(&mut self) { let i = match self.state.selected() { Some(i) => { if i <= 0 { self.items.len() - 1 } else { i - 1 } } None => self.items.len() - 1, }; self.state.select(Some(i)) } fn unselect(&mut self) { self.state.select(None) } fn selected(&self) -> Option<&T> { self.state.selected().map(|i| &self.items[i]) } } enum PageState { Images, Tags, } enum LoadingState { Init, Loading, Loaded, } struct App { registry_uri: String, username: String, password: String, registry: Option, page_state: PageState, loading_state: LoadingState, selected_image: Option, images: Option>, tags: Option>, } impl App { fn new(uri: String, username: String, password: String) -> App { let mut app = App { registry_uri: uri, username, password, registry: None, page_state: PageState::Images, loading_state: LoadingState::Init, selected_image: None, images: None, tags: None, }; let api = registry::api::Registry { endpoint: app.registry_uri.to_string(), user: app.username.to_string(), password: app.password.to_string(), }; app.registry = Some(api); app } } #[derive(Debug, Clone, Parser)] #[clap(version = "0.1.0")] struct Args { #[clap(short, long)] registry_uri: String, #[clap(short, long, default_value = "")] username: String, #[clap(short, long, default_value = "")] password: String, } fn main() -> Result<(), Box> { let args = Args::parse(); if args.registry_uri.is_empty() { println!("Please provide a registry URI"); return Ok(()); } enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let app = App::new(args.registry_uri, args.username, args.password); let res = run_app(&mut terminal, app); disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; if let Err(e) = res { println!("{}", e); } Ok(()) } fn run_app( terminal: &mut Terminal, mut app: App, ) -> Result<(), Box> { let mut last_tick = Instant::now(); loop { terminal.draw(|f| ui(f, &mut app))?; match app.loading_state { LoadingState::Init => { app.loading_state = LoadingState::Loading; loading(&mut app); } LoadingState::Loading => {} LoadingState::Loaded => {} } let timeout = Duration::from_millis(250) .checked_sub(last_tick.elapsed()) .unwrap_or(Duration::from_millis(0)); if crossterm::event::poll(timeout)? { let event = crossterm::event::read()?; if let Event::Key(key) = event { match key.code { KeyCode::Char('q') => return Ok(()), _ => {} } match app.page_state { PageState::Images => match key.code { KeyCode::Char('r') => { // reload list of images app.loading_state = LoadingState::Init; } KeyCode::Down => { if let Some(ref mut images) = app.images { images.next(); } } KeyCode::Up => { if let Some(ref mut images) = app.images { images.previous(); } } KeyCode::Enter => { app.selected_image = app.images.as_ref().unwrap().selected().cloned(); app.page_state = PageState::Tags; app.loading_state = LoadingState::Init; } _ => {} }, PageState::Tags => match key.code { KeyCode::Esc => { app.selected_image = None; app.tags = None; app.page_state = PageState::Images; } KeyCode::Down => { if let Some(ref mut tags) = app.tags { tags.next(); } } KeyCode::Up => { if let Some(ref mut tags) = app.tags { tags.previous(); } } KeyCode::Char('c') => { // copy docker image uri if let Some(ref image) = app.selected_image { if let Some(ref tags) = app.tags { if let Some(ref tag) = tags.selected() { let mut u = url::Url::parse(&app.registry_uri)?; u.set_path(image); let uri = u .to_string() .replace(&format!("{}://", u.scheme()).to_string(), ""); let uri_str = format!("{}:{}", uri, tag); let mut clipboard: ClipboardContext = ClipboardProvider::new()?; clipboard.set_contents(uri_str)?; } } } } _ => {} }, } } } if last_tick.elapsed() >= Duration::from_millis(250) { last_tick = Instant::now(); } } } fn loading(app: &mut App) -> Result<(), Box> { match app.page_state { PageState::Images => { if let Some(registry) = &app.registry { let images = registry.list_images()?; app.images = Some(StatefulList::with_items(images)); app.loading_state = LoadingState::Loaded; if let Some(ref mut images) = app.images { images.state.select(Some(0)); } } } PageState::Tags => { if let Some(ref image) = app.selected_image { let image_tags = app.registry.as_ref().unwrap().get_image_tags(image)?; app.tags = Some(StatefulList::with_items(image_tags.tags)); app.loading_state = LoadingState::Loaded; if let Some(ref mut tags) = app.tags { tags.state.select(Some(0)); } } } } Ok(()) } fn ui(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .constraints( [ Constraint::Percentage(10), Constraint::Percentage(5), Constraint::Percentage(85), ] .as_ref(), ) .split(f.size()); // chunk 0 is imfomation show_info(f, app, chunks[0]); // chunk 1 is the loading state match app.loading_state { LoadingState::Init | LoadingState::Loaded => { f.render_widget(Block::default().borders(Borders::ALL), chunks[1]); } LoadingState::Loading => { let txt = Spans::from(vec![Span::styled( "Loading...", Style::default().fg(Color::Yellow), )]); let paragraph = Paragraph::new(txt).block(Block::default().borders(Borders::ALL)); f.render_widget(paragraph, chunks[1]); } } match app.page_state { PageState::Images => page_images(f, app, chunks[2]), PageState::Tags => page_tags(f, app, chunks[2]), } } fn show_info(f: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .margin(1) .direction(Direction::Horizontal) .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) .split(area); let mut texts = vec![Spans::from(vec![ Span::raw("Registry: "), Span::raw(&app.registry_uri), ])]; if let Some(ref selected_image) = app.selected_image { texts.push(Spans::from(vec![ Span::raw("Selected Image: "), Span::raw(selected_image.to_owned()), ])); } let paragraph = Paragraph::new(texts).block(Block::default().borders(Borders::NONE)); f.render_widget(paragraph, chunks[0]); } fn page_images(f: &mut Frame, app: &mut App, area: Rect) { if let Some(ref mut images) = app.images { let items: Vec = images .items .iter() .map(|i| { let line = Spans::from(vec![Span::raw(i.as_str())]); ListItem::new(line) }) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL)) .highlight_style(Style::default().bg(Color::LightGreen).fg(Color::Black)); f.render_stateful_widget(list, area, &mut images.state); } } fn page_tags(f: &mut Frame, app: &mut App, area: Rect) { if let Some(ref mut tags) = app.tags { let items: Vec = tags .items .iter() .map(|i| { let line = Spans::from(vec![Span::raw(i.as_str())]); ListItem::new(line) }) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL)) .highlight_style(Style::default().bg(Color::LightGreen).fg(Color::Black)); f.render_stateful_widget(list, area, &mut tags.state); } }