440 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|