registry-cli/src/main.rs

440 lines
13 KiB
Rust

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<T> {
state: ListState,
items: Vec<T>,
}
impl<T> StatefulList<T> {
fn with_items(items: Vec<T>) -> StatefulList<T> {
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])
}
}
#[derive(PartialEq)]
enum PageState {
Images,
Tags,
}
#[derive(PartialEq)]
enum LoadingState {
Init,
Loading,
Loaded,
}
struct App {
registry_uri: String,
username: String,
password: String,
registry: Option<registry::api::Registry>,
page_state: PageState,
loading_state: LoadingState,
selected_image: Option<String>,
images: Option<StatefulList<String>>,
tags: Option<StatefulList<String>>,
}
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<dyn std::error::Error>> {
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<B: Backend>(
terminal: &mut Terminal<B>,
mut app: App,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<B: Backend>(f: &mut Frame<B>, 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<B: Backend>(f: &mut Frame<B>, 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]);
{
// show keys
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage(30),
Constraint::Percentage(30),
Constraint::Percentage(20),
]
.as_ref(),
)
.split(chunks[1]);
let mut global_keys = vec![Spans::from(vec![
Span::styled("q", Style::default().fg(Color::Yellow)),
Span::raw(" quit"),
])];
let mut tags_keys: Vec<Spans> = vec![];
if app.page_state == PageState::Tags {
global_keys.push(Spans::from(vec![
Span::styled("esc", Style::default().fg(Color::Yellow)),
Span::raw(" back to images"),
]));
tags_keys.push(Spans::from(vec![
Span::styled("c", Style::default().fg(Color::Yellow)),
Span::raw(" copy docker image uri"),
]));
}
let paragraph = Paragraph::new(global_keys).block(Block::default().borders(Borders::NONE));
f.render_widget(paragraph, chunks[0]);
let paragraph = Paragraph::new(tags_keys).block(Block::default().borders(Borders::NONE));
f.render_widget(paragraph, chunks[1]);
}
}
fn page_images<B: Backend>(f: &mut Frame<B>, app: &mut App, area: Rect) {
if let Some(ref mut images) = app.images {
let items: Vec<ListItem> = 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<B: Backend>(f: &mut Frame<B>, app: &mut App, area: Rect) {
if let Some(ref mut tags) = app.tags {
let items: Vec<ListItem> = 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);
}
}