Compare commits

...

65 Commits

Author SHA1 Message Date
Thomas Eppers
8f0eb3db4f removed faulty ghcr implementation
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2022-07-23 17:20:34 +02:00
Thomas Eppers
13350f872a Merge tag 'v1.2.1' into development 2022-07-23 00:50:57 +02:00
Thomas Eppers
c8915f828f bumped edition and version 2022-07-23 00:50:03 +02:00
Thomas Eppers
88070489f3 fixed clippy warnings 2022-07-23 00:47:25 +02:00
Thomas Eppers
d1ef5c6755 removed unused argument 2022-07-23 00:47:07 +02:00
Thomas Eppers
e98c5e7a12 changed format_time_nice to a trait that extends chrono::Duration 2022-07-23 00:38:44 +02:00
Thomas Eppers
6c83683a4a fixed clippy warnings
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-25 17:10:29 +01:00
Thomas Eppers
3bf2359392 added the ability to instantly delete default text in RepoEntry; simplified some code
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-25 17:08:22 +01:00
Thomas Eppers
bb5ea3a993 add variant information back to details view 2021-11-25 16:42:26 +01:00
Thomas Eppers
7d9cc21b6e removed old code 2021-11-25 14:06:43 +01:00
Thomas Eppers
e3865da563 removed old code; switched output to english 2021-11-25 14:02:56 +01:00
Thomas Eppers
881423e461 updated screenshot 2021-11-25 13:37:46 +01:00
Thomas Eppers
b6dbcd8eba Merge branch 'master' of ssh://gitea.eppixx.freeddns.org:10022/eppixx/reel-moby
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-24 18:06:04 +01:00
Thomas Eppers
04842af653 update gitignore file
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-24 17:57:52 +01:00
Thomas Eppers
bd60e57fea update 2021-11-24 17:56:15 +01:00
Thomas Eppers
e1d9cbe8c9 fixed some clippy warnings
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-24 17:49:23 +01:00
Thomas Eppers
987606d264 remove details in TagList; change ServiceSwitcher height 2021-11-24 17:47:06 +01:00
Thomas Eppers
b59c5f4ead added detail widget to no_yaml ui 2021-11-24 17:41:06 +01:00
Thomas Eppers
e3c6e01036 remove duplicate details 2021-11-24 17:34:43 +01:00
Thomas Eppers
a725161638 changed layout of default ui 2021-11-24 17:31:02 +01:00
Thomas Eppers
62f784fdb9 creating a widget for displaying tag details 2021-11-24 16:27:47 +01:00
Thomas Eppers
7720ed3102 restructured test; fixed a bug in regex for matching yaml image lines
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-16 13:12:12 +01:00
Thomas Eppers
6d40b03fbf added more comments
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-03 17:08:30 +01:00
Thomas Eppers
246124d4d1 added some comments
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-03 15:58:01 +01:00
Thomas Eppers
eec1836dd3 added some comments 2021-11-03 15:39:32 +01:00
Thomas Eppers
79577de0f9 take a generic object for displaying in the info widget 2021-11-03 15:06:54 +01:00
Thomas Eppers
72fd5ec46f updated screenshot
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-02 15:41:28 +01:00
Thomas Eppers
a41197562b nicer formatting for TagList
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-01 13:18:05 +01:00
Thomas Eppers
0ace41f545 removed clippy warnings 2021-11-01 13:09:09 +01:00
Thomas Eppers
cefe57b980 first working version with ghcr 2021-11-01 13:07:11 +01:00
Thomas Eppers
fe3f0579ad moved function to mod.rs; use generic structs in default.rs 2021-11-01 13:00:59 +01:00
Thomas Eppers
cb1a1c24b7 fixed some clippy warnings; added TODOs for later 2021-11-01 12:34:05 +01:00
Thomas Eppers
bc09317d4b fixed tests for DockerHub 2021-11-01 12:29:58 +01:00
Thomas Eppers
c0d376c79f created new generic structs for another registry than docker.hub 2021-11-01 12:27:51 +01:00
Thomas Eppers
cdb6babd48 bump version to version 1.0.0
All checks were successful
continuous-integration/woodpecker the build was successful
2021-10-31 22:52:06 +01:00
Thomas Eppers
8a35904c79 bumped version to 0.11.0
All checks were successful
continuous-integration/woodpecker the build was successful
2021-10-28 18:41:28 +02:00
Thomas Eppers
c55ccf005b updated screenshot
All checks were successful
continuous-integration/woodpecker the build was successful
2021-10-28 18:39:47 +02:00
Thomas Eppers
b6a234a833 imbed screenshot in README
All checks were successful
continuous-integration/woodpecker the build was successful
2021-10-28 17:43:04 +02:00
Thomas Eppers
2a0dee78a7 added woodpecker ci config 2021-10-28 17:42:37 +02:00
Thomas Eppers
b67242d0ea changed program parameter from -c to -f to match docker-compose 2021-10-27 14:15:12 +02:00
Thomas Eppers
7b8e613058 added more Hints for Input 2021-10-27 14:12:23 +02:00
Thomas Eppers
74006af796 added a check to not show an error when load next page of tags 2021-10-27 14:10:07 +02:00
Thomas Eppers
d608fe6b50 changed function next_page from Result to Option 2021-10-27 13:40:55 +02:00
Thomas Eppers
3cfbc2a656 removed unused code 2021-10-27 13:40:41 +02:00
Thomas Eppers
45f2bb64b0 fixed the rest of the clippy warnings; bumped version to 0.10.0 2021-10-23 13:26:09 +02:00
Thomas Eppers
a718f6f8fb started fixing clippy issues 2021-10-23 13:14:50 +02:00
Thomas Eppers
c9a8c637c4 rewrote TagList with a new behaviour 2021-10-22 17:23:37 +02:00
Thomas Eppers
a82119f827 removed sturctopt macro 2021-10-20 18:56:16 +02:00
Thomas Eppers
c3c653f7a6 changed the info message with an indicator why there is no panel with a docker-compose file 2021-10-20 18:55:01 +02:00
Thomas Eppers
47752720b4 created a new struct for a layout without opening a yaml file 2021-10-20 17:50:12 +02:00
Thomas Eppers
a7f509b165 started decoupling widgets and ui 2021-10-20 15:05:56 +02:00
Thomas Eppers
05a9669fec Merge branch 'dev/parameter' 2021-10-20 13:35:06 +02:00
Thomas Eppers
8f03f7c14b updated Cargo.lock 2021-10-20 13:21:14 +02:00
Thomas Eppers
14658e9253 implemented custom docker-compose file 2021-10-17 03:23:26 +02:00
Thomas Eppers
c8b2bf991c added parameter handling for binary 2021-10-17 02:37:28 +02:00
Thomas Eppers
4bda6e47be change name of the project and version number 2021-10-17 01:19:32 +02:00
Thomas Eppers
70ff0905e2 aded screenshot 2021-10-17 01:17:16 +02:00
Thomas Eppers
d6a40ef5ab added a readme file 2021-10-17 00:52:55 +02:00
Thomas Eppers
187a9cb749 added license file 2021-10-17 00:46:55 +02:00
Thomas Eppers
cb78c58de1 fixed repo tests 2021-10-17 00:17:40 +02:00
Thomas Eppers
0edfd45d38 changed formatting of output of image details 2021-10-17 00:12:31 +02:00
Thomas Eppers
0a844c42ee remove warnings of dead code 2021-09-24 16:36:16 +02:00
Thomas Eppers
a068e3f192 Merge branch 'master' of ssh://gitea.eppixx.freeddns.org:10022/eppixx/query-docker-tags into dev/repo-opject 2021-09-24 16:17:53 +02:00
Thomas Eppers
7828d7c703 added info of architecture and size of images 2021-09-23 22:48:19 +02:00
Thomas Eppers
67486f0042 removed warnings for unused argument 2021-09-23 22:48:04 +02:00
24 changed files with 1054 additions and 521 deletions

6
.gitignore vendored
View File

@ -1 +1,7 @@
# build files
/target
# test files
docker-compose.yml
docker-compose.yaml
docker-compose.yml.yml

37
.woodpecker.yml Normal file
View File

@ -0,0 +1,37 @@
# https://woodpecker-ci.org/docs/usage/intro
pipeline:
build_and_test:
image: rust
commands:
- cargo test
- cargo build --release
gitea_on_release:
# http://plugins.drone.io/drone-plugins/drone-gitea-release/
image: plugins/gitea-release
files: target/release/reel-moby
secrets: [gitea_release_api_key, gitea_release_base_url]
when:
event: tag
tag: v*
github_on_release:
# http://plugins.drone.io/drone-plugins/drone-github-release/
image: plugins/github-release
files: target/release/reel-moby
secrets: [github_release_api_key]
when:
event: tag
tag: v*
notify_when_failure:
# http://plugins.drone.io/appleboy/drone-discord/
image: appleboy/drone-discord
secrets: [ discord_webhook_id, discord_webhook_token]
message: "build {{build.number}} or release failed. Fix me please."
when:
status: failure
# http://plugins.drone.io/drone-plugins/drone-github-release/

148
Cargo.lock generated
View File

@ -11,6 +11,26 @@ dependencies = [
"memchr",
]
[[package]]
name = "ansi_term"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
dependencies = [
"winapi",
]
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.0.1"
@ -72,6 +92,21 @@ dependencies = [
"winapi",
]
[[package]]
name = "clap"
version = "2.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
dependencies = [
"ansi_term",
"atty",
"bitflags",
"strsim",
"textwrap",
"unicode-width",
"vec_map",
]
[[package]]
name = "core-foundation"
version = "0.9.1"
@ -213,6 +248,15 @@ version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
[[package]]
name = "heck"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "hermit-abi"
version = "0.1.19"
@ -527,6 +571,30 @@ version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857"
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.28"
@ -536,20 +604,6 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "query-docker-tags"
version = "0.1.0"
dependencies = [
"chrono",
"lazy_static",
"regex",
"reqwest",
"serde",
"serde_json",
"termion",
"tui",
]
[[package]]
name = "quote"
version = "1.0.9"
@ -617,6 +671,21 @@ dependencies = [
"redox_syscall",
]
[[package]]
name = "reel-moby"
version = "1.2.1"
dependencies = [
"chrono",
"lazy_static",
"regex",
"reqwest",
"serde",
"serde_json",
"structopt",
"termion",
"tui",
]
[[package]]
name = "regex"
version = "1.5.4"
@ -776,6 +845,36 @@ dependencies = [
"winapi",
]
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "structopt"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf9d950ef167e25e0bdb073cf1d68e9ad2795ac826f2f3f59647817cf23c0bfa"
dependencies = [
"clap",
"lazy_static",
"structopt-derive",
]
[[package]]
name = "structopt-derive"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "134d838a2c9943ac3125cf6df165eda53493451b719f3255b2a26b85f772d0ba"
dependencies = [
"heck",
"proc-macro-error",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "1.0.74"
@ -813,6 +912,15 @@ dependencies = [
"redox_termios",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
]
[[package]]
name = "time"
version = "0.1.44"
@ -975,6 +1083,18 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version_check"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
[[package]]
name = "want"
version = "0.3.0"

View File

@ -1,8 +1,8 @@
[package]
name = "query-docker-tags"
version = "0.1.0"
edition = "2018"
name = "reel-moby"
version = "1.2.1"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -15,6 +15,7 @@ tui = "0.16"
termion = "1.5"
regex = "1.5.4"
lazy_static = "1.4.0"
structopt = "0.3.23"
[profile.release]
lto = "yes"

9
LICENSE Normal file
View File

@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright © 2021 Thomas Eppers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

10
README.md Normal file
View File

@ -0,0 +1,10 @@
# reel-moby
search for new docker tags and update them in your docker-compose file
## Usage
Searches the current folder for a docker-compose.(yml|yaml) file and opens it when it found one. Then it is possible to select a image line. The program then shows the found repository and shows the latest tags. The tags can be scrolled and selected, which updates the opened file.
From that point save the file and pull the new image with `docker-compose up -d` or `docker-compse pull`.
![screenshot](./screenshot.png)

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -1,5 +0,0 @@
pub fn remove_last_char(input: &str) -> &str {
let mut chars = input.chars();
chars.next_back();
chars.as_str()
}

View File

@ -0,0 +1,28 @@
pub trait DisplayDurationExt {
/// displays a duration in a human readable form
fn display(&self) -> String;
}
impl DisplayDurationExt for chrono::Duration {
fn display(&self) -> String {
if self.num_weeks() == 52 {
format!("{} Year", (self.num_weeks() / 52) as i32)
} else if self.num_weeks() > 103 {
format!("{} Years", (self.num_weeks() / 52) as i32)
} else if self.num_days() == 1 {
format!("{} Day", self.num_days())
} else if self.num_days() > 1 {
format!("{} Days", self.num_days())
} else if self.num_hours() == 1 {
format!("{} Hour", self.num_hours())
} else if self.num_hours() > 1 {
format!("{} Hours", self.num_hours())
} else if self.num_minutes() == 1 {
format!("{} Minute", self.num_minutes())
} else if self.num_minutes() > 1 {
format!("{} Minutes", self.num_minutes())
} else {
format!("{} Seconds", self.num_seconds())
}
}
}

1
src/common/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod display_duration_ext;

View File

@ -1,9 +1,26 @@
use std::path::PathBuf;
use structopt::StructOpt;
mod common;
mod repo;
mod tags;
mod repository;
mod ui;
mod widget;
fn main() {
ui::Ui::run("enter a repository or select one from docker-compose.yml");
/// helps you searching or updating tags of your used docker images
#[derive(StructOpt, Debug)]
pub struct Opt {
/// A custom path to a docker-compose file
#[structopt(short, long, parse(from_os_str))]
file: Option<PathBuf>,
/// Give a Repository identifier, e.g. library/nginx
#[structopt(short, long, parse(from_str))]
repo: Option<String>,
}
fn main() {
//parse parameter
let opt = Opt::from_args();
ui::create_ui(&opt);
}

View File

@ -2,24 +2,16 @@ use std::fmt;
use regex::Regex;
// use crate::common;
#[derive(Debug, PartialEq)]
pub enum Error {
// Conversion,
// Empty,
NoTagFound,
// InvalidChar,
MisformedInput,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
// Error::Conversion => write!(f, "Conversion error"),
// Error::Empty => write!(f, "Input is empty"),
Error::NoTagFound => write!(f, "Expected a tag"),
// Error::InvalidChar => write!(f, "Invalid character found"),
Error::MisformedInput => write!(f, "Unexpected input"),
}
}
@ -33,18 +25,22 @@ pub enum Repo {
}
/// check if yaml line matches and returns the split of repo string and rest
pub fn match_yaml_image(input: &str) -> Option<(&str, &str)> {
/// the first &str is the image tag
/// it will be used to not change the identation
/// the second &str will the the identifier for the image
pub fn match_yaml_image(input: &str) -> Result<(&str, &str), Error> {
lazy_static::lazy_static! {
static ref REGEX: Regex = Regex::new(r"^( +image *: *)([a-z0-9\./:]+)").unwrap();
static ref REGEX: Regex = Regex::new(r"^( +image *: *)([a-z0-9\-\./:]+)").unwrap();
}
let caps = match REGEX.captures(input) {
Some(caps) => caps,
None => return None,
None => return Err(Error::NoTagFound),
};
Some((caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str()))
Ok((caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str()))
}
/// takes the identifier and splits off the tag it exists
pub fn split_tag_from_repo(input: &str) -> Result<(&str, &str), Error> {
lazy_static::lazy_static! {
static ref REGEX: Regex = Regex::new(r"^([a-z0-9\./[^:]]*):?([a-z0-9._\-]*)").unwrap();
@ -67,17 +63,10 @@ pub fn split_tag_from_repo(input: &str) -> Result<(&str, &str), Error> {
Ok((front, back))
}
// pub fn split_repo(repo: &str) -> Result<Repo, Error> {
// let split_tag: Vec<&str> = repo.split(":").collect();
// if split_tag.len() == 2 && split_tag[0].len() != 0 && split_tag[1].len() != 0 {
// //
// }
// Ok(Repo::Project("".into()))
// }
/// takes an identifier and changes it to a Repo enum
pub fn split_repo_without_tag(repo: &str) -> Result<Repo, Error> {
let repo = repo.trim();
let split_repo: Vec<&str> = repo.split("/").collect();
let split_repo: Vec<&str> = repo.split('/').collect();
match split_repo.len() {
1 => {
let regex = regex::Regex::new(r"[a-z0-9]+").unwrap();
@ -108,118 +97,85 @@ pub fn split_repo_without_tag(repo: &str) -> Result<Repo, Error> {
}
}
pub fn split_tag(repo: &str) -> Result<(&str, &str), Error> {
let split_tag: Vec<&str> = repo.split(":").collect();
if split_tag.len() == 2 && split_tag[0].len() != 0 && split_tag[1].len() != 0 {
Ok((split_tag[0], split_tag[1]))
} else {
Err(Error::NoTagFound)
}
}
// pub fn extract(repo: &str) -> Result<(Option<&str>, Option<&str>, &str), Error> {
// if repo.len() == 0 {
// return Err(Error::Empty);
// }
// let regex = regex::Regex::new(r"([^/:]*?/)??([^/:]*?/)?([^/:]*):?(.*)?").unwrap();
// let caps = match regex.captures(repo) {
// None => return Err(Error::Conversion),
// Some(cap) => cap,
// };
// let server = match caps.get(1) {
// None => None,
// Some(cap) => Some(common::remove_last_char(cap.as_str())),
// };
// let orga = match caps.get(2) {
// None => None,
// Some(cap) => Some(common::remove_last_char(cap.as_str())),
// };
// Ok((server, orga, caps.get(3).unwrap().as_str()))
// }
#[cfg(test)]
mod tests {
use crate::repo;
use crate::repo::{Error, Repo};
// #[test]
// fn test_repo_regex() {
// assert_eq!(repo::extract(""), Err(repo::Error::Empty));
// assert_eq!(
// repo::extract("ghcr.io/library/nginx"),
// Ok((Some("ghcr.io"), Some("library"), "nginx"))
// );
// assert_eq!(
// repo::extract("library/nginx"),
// Ok((None, Some("library"), "nginx"))
// );
// assert_eq!(repo::extract("nginx"), Ok((None, None, "nginx")));
// }
#[test]
fn split_tag() {
assert_eq!(repo::split_tag("nginx:v1"), Ok(("nginx", "v1")));
assert_eq!(repo::split_tag("dsfsdf"), Err(repo::Error::NoTagFound));
assert_eq!(repo::split_tag("nginx:"), Err(repo::Error::NoTagFound));
assert_eq!(repo::split_tag(":v1"), Err(repo::Error::NoTagFound));
assert_eq!(repo::split_tag(":"), Err(repo::Error::NoTagFound));
}
#[test]
fn test_split_repo_without_tag() {
use crate::repo::split_repo_without_tag as test_fn;
assert_eq!(test_fn(""), Err(Error::MisformedInput));
assert_eq!(test_fn("NGINX"), Err(Error::MisformedInput));
assert_eq!(test_fn("nginx"), Ok(Repo::Project("nginx".into())));
assert_eq!(
test_fn("library/nginx"),
Ok(Repo::WithOrga("library".into(), "nginx".into()))
);
assert_eq!(
test_fn("ghcr.io/library/nginx"),
Ok(Repo::WithServer(
"ghcr.io".into(),
"library".into(),
"nginx".into(),
))
);
let input: Vec<(&str, Result<Repo, Error>)> = vec![
("", Err(Error::MisformedInput)),
("NGINX", Err(Error::MisformedInput)),
("nginx", Ok(Repo::Project("nginx".into()))),
(
"library/nginx",
Ok(Repo::WithOrga("library".into(), "nginx".into())),
),
(
"ghcr.io/library/nginx",
Ok(Repo::WithServer(
"ghcr.io".into(),
"library".into(),
"nginx".into(),
)),
),
(
"te-st/test-hypen",
Ok(Repo::WithOrga("te-st".into(), "test-hypen".into())),
),
(
"test/test.dot",
Ok(Repo::WithOrga("test".into(), "test.dot".into())),
),
];
for i in input {
assert_eq!(super::split_repo_without_tag(i.0), i.1);
}
}
#[test]
fn test_match_yaml_image() {
use crate::repo::match_yaml_image as test_fn;
assert_eq!(test_fn(""), None);
assert_eq!(test_fn("version: '2'"), None);
assert_eq!(test_fn("image: "), None);
assert_eq!(test_fn(" image: "), None);
assert_eq!(test_fn(" image: nginx"), Some((" image: ", "nginx")));
assert_eq!(
test_fn(" image: library/nginx"),
Some((" image: ", "library/nginx"))
);
assert_eq!(
test_fn(" image: ghcr.io/library/nginx"),
Some((" image: ", "ghcr.io/library/nginx"))
);
assert_eq!(test_fn("# image: nginx"), None);
assert_eq!(
test_fn(" image: nginx #comment"),
Some((" image: ", "nginx"))
);
let input: Vec<(&str, Result<(&str, &str), Error>)> = vec![
("", Err(Error::NoTagFound)),
("version: '2'", Err(Error::NoTagFound)),
("image: ", Err(Error::NoTagFound)),
(" image: ", Err(Error::NoTagFound)),
(" image: nginx", Ok((" image: ", "nginx"))),
(" image: library/nginx", Ok((" image: ", "library/nginx"))),
(
" image: gchr.io/library/nginx",
Ok((" image: ", "gchr.io/library/nginx")),
),
(" image: nginx # comment", Ok((" image: ", "nginx"))),
(" image: test-hyphen", Ok((" image: ", "test-hyphen"))),
(" image: test.dot", Ok((" image: ", "test.dot"))),
];
for i in input {
assert_eq!(super::match_yaml_image(i.0), i.1);
}
}
#[test]
fn test_split_tag_from_repo() {
use crate::repo::split_tag_from_repo as test_fn;
assert_eq!(test_fn("nginx"), Ok(("nginx", "")));
assert_eq!(test_fn("library/nginx"), Ok(("library/nginx", "")));
assert_eq!(
test_fn("ghcr.io/library/nginx"),
Ok(("ghcr.io/library/nginx", ""))
);
assert_eq!(test_fn("nginx:"), Ok(("nginx", "")));
assert_eq!(test_fn("nginx:1"), Ok(("nginx", "1")));
assert_eq!(test_fn("nginx:latest"), Ok(("nginx", "latest")));
let input: Vec<(&str, Result<(&str, &str), super::Error>)> = vec![
("nginx", Ok(("nginx", ""))),
("library/nginx", Ok(("library/nginx", ""))),
("ghcr.io/library/nginx", Ok(("ghcr.io/library/nginx", ""))),
("nginx:", Ok(("nginx", ""))),
("nginx:1", Ok(("nginx", "1"))),
("nginx:latest", Ok(("nginx", "latest"))),
("hy-phen:latest", Ok(("hy-phen", "latest"))),
("test.dot:latest", Ok(("test.dot", "latest"))),
(
"woodpeckerci/woodpecker-server",
Ok(("woodpeckerci/woodpecker-server", "")),
),
];
for i in input {
assert_eq!(super::split_tag_from_repo(i.0), i.1);
}
}
}

View File

@ -0,0 +1,76 @@
use serde::Deserialize;
use crate::repository::Error;
#[derive(Deserialize, Debug, Clone)]
struct ImageDetails {
architecture: String,
os: String,
variant: Option<String>,
size: usize,
}
#[derive(Deserialize, Clone)]
pub struct Images {
images: Vec<ImageDetails>,
#[serde(rename(deserialize = "name"))]
tag_name: String,
last_updated: String,
}
impl Images {
pub fn convert(&self) -> super::Tag {
super::Tag {
name: self.tag_name.clone(),
last_updated: Some(self.last_updated.clone()),
details: self
.images
.iter()
.map(|d| super::TagDetails {
arch: Some(d.architecture.clone()),
variant: Some(d.variant.clone().unwrap_or_default()),
os: Some(d.os.clone()),
size: Some(d.size),
})
.collect(),
}
}
}
#[derive(Deserialize)]
pub struct DockerHub {
#[serde(rename(deserialize = "next"))]
next_page: Option<String>,
results: Vec<Images>,
}
impl DockerHub {
/// fetches tag information with a repository name in the form of organization/repository or library/repository in the case of official images from docker
pub fn create_repo(repo: &str) -> Result<super::Repo, Error> {
let request = format!("https://hub.docker.com/v2/repositories/{}/tags", repo);
Self::with_url(&request)
}
/// fetches tag information from a url
pub fn with_url(url: &str) -> Result<super::Repo, Error> {
let response = match reqwest::blocking::get(url) {
Ok(result) => result,
Err(e) => return Err(Error::Fetching(format!("reqwest error: {}", e))),
};
//convert it to json
let tags = match response.json::<Self>() {
Ok(result) => result,
Err(e) => return Err(Error::Converting(format!("invalid json: {}", e))),
};
if tags.results.is_empty() {
return Err(Error::NoTagsFound);
}
Ok(super::Repo {
tags: tags.results.iter().map(|t| t.convert()).collect(),
next_page: tags.next_page,
})
}
}

137
src/repository/mod.rs Normal file
View File

@ -0,0 +1,137 @@
mod dockerhub;
use std::fmt;
use chrono::DateTime;
use crate::common::display_duration_ext::DisplayDurationExt;
use crate::repo;
#[derive(Debug, PartialEq)]
pub enum Error {
/// couldn't fetch json with reqwest
Fetching(String),
/// a serde error
Converting(String),
/// invalid repos show a valid json with 0 tags
NoTagsFound,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Fetching(s) => write!(f, "Fetching error: {}", s),
Error::Converting(s) => write!(f, "Converting error: {}", s),
Error::NoTagsFound => write!(f, "Given Repo has 0 tags. Is it valid?"),
}
}
}
#[derive(Clone, PartialEq)]
pub struct TagDetails {
pub arch: Option<String>,
pub variant: Option<String>,
pub os: Option<String>,
pub size: Option<usize>,
}
#[derive(Clone)]
pub struct Tag {
name: String,
details: Vec<TagDetails>,
last_updated: Option<String>,
}
impl Tag {
pub fn get_name(&self) -> &str {
&self.name
}
pub fn get_name_with_details(&self) -> String {
let dif = match &self.last_updated {
None => "".to_string(),
Some(last_updated) => {
let now = chrono::Utc::now();
let rfc3339 = DateTime::parse_from_rfc3339(last_updated).unwrap();
let dif = now - rfc3339.with_timezone(&chrono::Utc);
format!(", {} old", dif.display())
}
};
if dif.is_empty() {}
format!("{}{}", self.name, dif)
}
pub fn get_details(&self) -> &Vec<TagDetails> {
&self.details
}
}
pub struct Repo {
tags: Vec<Tag>,
next_page: Option<String>,
}
impl Repo {
pub fn new(repo: &str) -> Result<Self, Error> {
use crate::repo::Repo;
let (registry, repo) = match crate::repo::split_repo_without_tag(repo) {
Ok(Repo::WithServer(reg, org, pro)) => (Some(reg), format!("{}/{}", org, pro)),
Ok(Repo::WithOrga(org, pro)) => (None, format!("{}/{}", org, pro)),
Ok(Repo::Project(pro)) => (None, format!("library/{}", pro)),
Err(e) => return Err(Error::Converting(format!("{}", e))),
};
if registry.unwrap_or_default().is_empty() {
dockerhub::DockerHub::create_repo(&repo)
} else {
return Err(Error::Converting("This registry is not supported".into()));
}
}
pub fn with_url(url: &str) -> Result<Self, Error> {
//TODO fix for other registries
dockerhub::DockerHub::with_url(url)
}
pub fn get_tags(&self) -> &Vec<Tag> {
&self.tags
}
pub fn next_page(&self) -> Option<Self> {
match &self.next_page {
Some(url) => match Self::with_url(url) {
Ok(tags) => Some(tags),
Err(_) => None,
},
None => None,
}
}
}
/// checks the repo name and may add a prefix for official images
pub fn check_repo(name: &str) -> Result<String, Error> {
let repo = match repo::split_tag_from_repo(name) {
Err(e) => return Err(Error::Converting(format!("{}", e))),
Ok((name, _)) => name,
};
match repo::split_repo_without_tag(name) {
Ok(repo::Repo::Project(s)) => Ok(format!("library/{}", s)),
Ok(_) => Ok(repo.to_string()),
Err(e) => Err(Error::Converting(format!("{}", e))),
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_check_repo() {
assert_eq!(super::check_repo("nginx").unwrap(), "library/nginx");
assert_eq!(super::check_repo("library/nginx").unwrap(), "library/nginx");
assert_eq!(
super::check_repo("rocketchat/rocket.chat").unwrap(),
"rocketchat/rocket.chat"
);
}
}

View File

@ -1,162 +0,0 @@
use std::fmt;
use crate::repo;
use chrono::DateTime;
use serde::Deserialize;
#[derive(Deserialize)]
struct ImageDetails {
architecture: String,
os: String,
size: usize,
}
#[derive(Deserialize)]
pub struct Images {
images: Vec<ImageDetails>,
#[serde(rename(deserialize = "name"))]
pub tag_name: String,
last_updated: String,
}
#[derive(Deserialize)]
pub struct Tags {
count: usize,
#[serde(rename(deserialize = "next"))]
next_page: Option<String>,
#[serde(rename(deserialize = "previous"))]
prev_page: Option<String>,
pub results: Vec<Images>,
}
#[derive(Debug, PartialEq)]
pub enum Error {
/// repo string contains an illegal character
InvalidCharacter(char),
/// couldn't fetch json with reqwest
Fetching(String),
/// a serde error
Converting(String),
/// invalid repos show a valid json with 0 tags
NoTagsFound,
NoPrevPage,
NoNextPage,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::InvalidCharacter(c) => write!(f, "Invalid Character: {}", c),
Error::Fetching(s) => write!(f, "Fetching error: {}", s),
Error::Converting(s) => write!(f, "Converting error: {}", s),
Error::NoNextPage => write!(f, "No next page available"),
Error::NoPrevPage => write!(f, "No previous page available"),
Error::NoTagsFound => write!(f, "Given Repo has 0 tags. Is it valid?"),
}
}
}
impl Tags {
/// fetches tag information with a repository name in the form of organization/repository or library/repository in the case of official images from docker
pub fn new(repo: String) -> Result<Self, Error> {
let request = format!("https://hub.docker.com/v2/repositories/{}/tags", repo);
Self::with_url(&request)
}
/// fetches tag information from a url
fn with_url(url: &str) -> Result<Self, Error> {
let res = match reqwest::blocking::get(url) {
Ok(result) => result,
Err(e) => return Err(Error::Fetching(format!("reqwest error: {}", e))),
};
//convert it to json
let raw = res.text().unwrap();
let tags: Self = match serde_json::from_str(&raw) {
Ok(result) => result,
Err(e) => return Err(Error::Converting(format!("invalid json: {}", e))),
};
if tags.count == 0 {
return Err(Error::NoTagsFound);
}
Ok(tags)
}
/// checks the repo name and may add a prefix for official images
pub fn check_repo(name: &str) -> Result<String, Error> {
let repo = match repo::split_tag_from_repo(name) {
Err(e) => return Err(Error::Converting(format!("{}", e))),
Ok((name, _)) => name,
};
match repo::split_repo_without_tag(name) {
Ok(repo::Repo::Project(s)) => Ok(format!("library/{}", s)),
Ok(_) => Ok(repo.to_string()),
Err(e) => Err(Error::Converting(format!("{}", e))),
}
}
/// returns tags of next page
pub fn next_page(&self) -> Result<Self, Error> {
match &self.next_page {
Some(url) => Self::with_url(url),
None => Err(Error::NoNextPage),
}
}
/// returns tags of previous page
pub fn prev_page(&self) -> Result<Self, Error> {
match &self.prev_page {
Some(url) => Self::with_url(url),
None => Err(Error::NoPrevPage),
}
}
}
impl fmt::Display for Images {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let now = chrono::Utc::now();
let rfc3339 = DateTime::parse_from_rfc3339(&self.last_updated).unwrap();
let dif = now - rfc3339.with_timezone(&chrono::Utc);
write!(f, "{} vor {}", self.tag_name, format_time_nice(dif))
}
}
/// converts a given duration to a readable string
fn format_time_nice(time: chrono::Duration) -> String {
if time.num_weeks() == 52 {
format!("{} Jahr", (time.num_weeks() / 52) as i32)
} else if time.num_weeks() > 103 {
format!("{} Jahren", (time.num_weeks() / 52) as i32)
} else if time.num_days() == 1 {
format!("{} Tag", time.num_days())
} else if time.num_days() > 1 {
format!("{} Tagen", time.num_days())
} else if time.num_hours() == 1 {
format!("{} Stunde", time.num_hours())
} else if time.num_hours() > 1 {
format!("{} Stunden", time.num_hours())
} else if time.num_minutes() == 1 {
format!("{} Minute", time.num_minutes())
} else if time.num_minutes() > 1 {
format!("{} Minuten", time.num_minutes())
} else {
format!("{} Sekunden", time.num_seconds())
}
}
#[cfg(test)]
mod tests {
use crate::tags::{Error, Tags};
#[test]
fn test_check_repo() {
assert_eq!(Tags::check_repo("nginx").unwrap(), "library/nginx");
assert_eq!(Tags::check_repo("library/nginx").unwrap(), "library/nginx");
assert_eq!(
Tags::check_repo("rocketchat/rocket.chat").unwrap(),
"rocketchat/rocket.chat"
);
}
}

View File

@ -1,23 +1,24 @@
use std::sync::mpsc;
use std::{io, thread};
use crate::Opt;
use termion::event::Key;
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use tui::backend::TermionBackend;
use tui::layout::{Constraint, Direction, Layout};
use tui::Terminal;
use crate::repository;
use crate::widget::info;
use crate::widget::repo_entry;
use crate::widget::service_switcher;
use crate::widget::tag_list;
pub struct Ui<'a> {
pub struct Ui {
state: State,
repo: crate::widget::repo_entry::RepoEntry,
tags: crate::widget::tag_list::TagList,
services: crate::widget::service_switcher::ServiceSwitcher<'a>,
services: crate::widget::service_switcher::ServiceSwitcher,
details: crate::widget::details::Details,
info: crate::widget::info::Info,
}
@ -28,6 +29,16 @@ pub enum State {
SelectService,
}
impl std::fmt::Display for State {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
State::EditRepo => write!(f, "Edit repository"),
State::SelectTag => write!(f, "Select a tag"),
State::SelectService => write!(f, "Select a image"),
}
}
}
impl std::iter::Iterator for State {
type Item = Self;
@ -41,23 +52,30 @@ impl std::iter::Iterator for State {
}
}
impl Ui<'_> {
pub fn run(repo_id: &str) {
impl Ui {
pub fn run(opt: &Opt) {
let repo_id = opt.repo.as_deref();
let mut ui = Ui {
state: State::SelectService,
repo: repo_entry::RepoEntry::new(repo_id),
tags: tag_list::TagList::with_status("Tags are empty"),
services: service_switcher::ServiceSwitcher::new(),
services: service_switcher::ServiceSwitcher::new(&opt.file).unwrap(),
details: crate::widget::details::Details::new(),
info: info::Info::new("Select image of edit Repository"),
};
if opt.repo.is_none() {
ui.tags = tag_list::TagList::with_repo_name(ui.repo.get());
}
//setup tui
let stdout = io::stdout().into_raw_mode().unwrap();
let backend = TermionBackend::new(stdout);
let mut terminal = Terminal::new(backend).unwrap();
//setup input thread
let receiver = ui.spawn_stdin_channel();
let receiver = super::spawn_stdin_channel();
//core interaction loop
'core: loop {
@ -68,7 +86,7 @@ impl Ui<'_> {
.direction(Direction::Vertical)
.constraints(
[
Constraint::Min(9),
Constraint::Length(10),
Constraint::Length(3),
Constraint::Min(7),
Constraint::Length(2),
@ -77,11 +95,16 @@ impl Ui<'_> {
)
.split(rect.size());
let (list, state) = ui.services.render(&ui.state);
let (list, state) = ui.services.render(ui.state == State::SelectService);
rect.render_stateful_widget(list, chunks[0], state);
rect.render_widget(ui.repo.render(&ui.state), chunks[1]);
let (list, state) = ui.tags.render(&ui.state);
rect.render_stateful_widget(list, chunks[2], state);
rect.render_widget(ui.repo.render(ui.state == State::EditRepo), chunks[1]);
let (list, state) = ui.tags.render(ui.state == State::SelectTag);
let more_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(15), Constraint::Length(28)].as_ref())
.split(chunks[2]);
rect.render_stateful_widget(list, more_chunks[0], state);
rect.render_widget(ui.details.render(), more_chunks[1]);
rect.render_widget(ui.info.render(), chunks[3]);
})
.unwrap();
@ -91,67 +114,62 @@ impl Ui<'_> {
Ok(Key::Ctrl('q')) => break 'core, //quit program without saving
Ok(Key::Char('\t')) => {
ui.state.next();
()
ui.info.set_info(&ui.state);
}
Ok(Key::Ctrl('s')) => match ui.services.save() {
Err(e) => {
ui.info.set_info(&format!("{}", e));
continue;
}
Ok(_) => ui.info.set_info("Saved compose file"),
Ok(_) => ui.info.set_text("Saved compose file"),
},
Ok(Key::Ctrl('r')) => {
ui.repo.confirm();
ui.tags = tag_list::TagList::with_repo(ui.repo.get());
ui.tags = tag_list::TagList::with_repo_name(ui.repo.get());
}
Ok(Key::Ctrl('n')) => match ui.tags.next_page() {
Err(e) => ui.info.set_info(&format!("{}", e)),
Ok(_) => (),
},
Ok(Key::Ctrl('p')) => match ui.tags.prev_page() {
Err(e) => ui.info.set_info(&format!("{}", e)),
Ok(_) => (),
},
Ok(Key::Char('\n')) => match ui.state {
State::EditRepo => {
ui.repo.confirm();
ui.tags = tag_list::TagList::with_repo(ui.repo.get());
ui.tags = tag_list::TagList::with_repo_name(ui.repo.get());
}
State::SelectTag => {
let mut repo = ui.repo.get();
let tag = match ui.tags.get_selected() {
Err(tag_list::Error::NextPageSelected) => continue,
Err(e) => {
ui.info.set_info(&format!("{}", e));
continue;
}
Ok(tag) => tag,
};
repo.push_str(":");
repo.push(':');
repo.push_str(&tag);
ui.services.change_current_line(repo);
}
_ => (),
},
Ok(Key::Char(key)) => {
if ui.state == State::EditRepo {
ui.info.set_info("Editing Repository");
Ok(Key::Char(key)) => match ui.state {
State::SelectService => (),
State::EditRepo => {
ui.info.set_text("Editing Repository");
ui.repo.handle_input(Key::Char(key));
}
ui.repo.handle_input(&ui.state, Key::Char(key));
ui.tags.handle_input(&ui.state, Key::Char(key));
}
Ok(Key::Backspace) => {
if ui.state == State::EditRepo {
ui.info.set_info("Editing Repository");
State::SelectTag => (),
},
Ok(Key::Backspace) => match ui.state {
State::SelectService => (),
State::EditRepo => {
ui.info.set_text("Editing Repository");
ui.repo.handle_input(Key::Backspace);
}
ui.repo.handle_input(&ui.state, Key::Backspace);
ui.tags.handle_input(&ui.state, Key::Backspace);
}
Ok(Key::Up) => {
if ui.state == State::SelectService && ui.services.find_previous_match() {
State::SelectTag => (),
},
Ok(Key::Up) => match ui.state {
State::SelectService if ui.services.find_previous_match() => {
match ui.services.extract_repo() {
Err(e) => ui.info.set_info(&format!("{}", e)),
Ok(s) => {
let repo = match crate::tags::Tags::check_repo(&s) {
let repo = match repository::check_repo(&s) {
Err(e) => {
ui.info.set_info(&format!("{}", e));
continue;
@ -159,19 +177,23 @@ impl Ui<'_> {
Ok(s) => s,
};
ui.repo.set(repo.to_string());
ui.tags = tag_list::TagList::with_repo(ui.repo.get());
ui.tags = tag_list::TagList::with_repo_name(ui.repo.get());
}
}
}
ui.tags.handle_input(&ui.state, Key::Up);
ui.repo.handle_input(&ui.state, Key::Up);
}
State::SelectService => (),
State::EditRepo => (),
State::SelectTag => {
ui.tags.handle_input(Key::Up);
ui.details = ui.tags.create_detail_widget();
}
},
Ok(Key::Down) => match ui.state {
State::SelectService if ui.services.find_next_match() => {
match ui.services.extract_repo() {
Err(e) => ui.info.set_info(&format!("{}", e)),
Ok(s) => {
let repo = match crate::tags::Tags::check_repo(&s) {
let repo = match repository::check_repo(&s) {
Err(e) => {
ui.info.set_info(&format!("{}", e));
continue;
@ -179,19 +201,17 @@ impl Ui<'_> {
Ok(s) => s,
};
ui.repo.set(repo.to_string());
ui.tags = tag_list::TagList::with_repo(ui.repo.get());
ui.tags = tag_list::TagList::with_repo_name(ui.repo.get());
}
}
}
_ => {
ui.tags.handle_input(&ui.state, Key::Down);
ui.repo.handle_input(&ui.state, Key::Down);
State::SelectService => (),
State::EditRepo => (),
State::SelectTag => {
ui.tags.handle_input(Key::Down);
ui.details = ui.tags.create_detail_widget();
}
},
Ok(key) => {
ui.repo.handle_input(&ui.state, Key::Down);
ui.tags.handle_input(&ui.state, key);
}
_ => (),
}
@ -201,18 +221,4 @@ impl Ui<'_> {
terminal.clear().unwrap();
}
/// create a thread for catching input and send them to core loop
pub fn spawn_stdin_channel(&self) -> mpsc::Receiver<termion::event::Key> {
let (tx, rx) = mpsc::channel::<termion::event::Key>();
thread::spawn(move || loop {
let stdin = io::stdin();
for c in stdin.keys() {
tx.send(c.unwrap()).unwrap();
}
});
thread::sleep(std::time::Duration::from_millis(64));
rx
}
}

32
src/ui/mod.rs Normal file
View File

@ -0,0 +1,32 @@
mod default;
mod no_yaml;
use std::sync::mpsc;
use std::{io, thread};
use crate::Opt;
use termion::input::TermRead;
use crate::widget::service_switcher;
pub fn create_ui(opt: &Opt) {
let service_result = service_switcher::ServiceSwitcher::new(&opt.file);
match service_result {
None => no_yaml::NoYaml::run(opt),
Some(_) => default::Ui::run(opt),
}
}
/// create a thread for catching input and send them to core loop
pub fn spawn_stdin_channel() -> mpsc::Receiver<termion::event::Key> {
let (tx, rx) = mpsc::channel::<termion::event::Key>();
thread::spawn(move || loop {
let stdin = io::stdin();
for c in stdin.keys() {
tx.send(c.unwrap()).unwrap();
}
});
thread::sleep(std::time::Duration::from_millis(64));
rx
}

161
src/ui/no_yaml.rs Normal file
View File

@ -0,0 +1,161 @@
use std::{io, thread};
use termion::event::Key;
use termion::raw::IntoRawMode;
use tui::backend::TermionBackend;
use tui::layout::{Constraint, Direction, Layout};
use tui::Terminal;
use crate::widget::details;
use crate::widget::info;
use crate::widget::repo_entry;
use crate::widget::tag_list;
use crate::Opt;
#[derive(PartialEq, Clone)]
pub enum State {
EditRepo,
SelectTag,
}
impl std::fmt::Display for State {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
State::EditRepo => write!(f, "Edit repository"),
State::SelectTag => write!(f, "Select a tag"),
}
}
}
impl std::iter::Iterator for State {
type Item = Self;
fn next(&mut self) -> Option<Self::Item> {
match self {
State::EditRepo => *self = State::SelectTag,
State::SelectTag => *self = State::EditRepo,
}
Some(self.clone())
}
}
pub struct NoYaml {
state: State,
repo: repo_entry::RepoEntry,
tags: tag_list::TagList,
details: details::Details,
info: info::Info,
}
impl NoYaml {
pub fn run(opt: &Opt) {
let repo_id = opt.repo.as_deref();
let mut ui = NoYaml {
state: State::EditRepo,
repo: repo_entry::RepoEntry::new(repo_id),
tags: tag_list::TagList::with_status("Tags are empty"),
details: details::Details::new(),
info: info::Info::new("could not find a docker-compose file"),
};
// load tags if a repository was given thorugh paramter
if opt.repo.is_none() {
ui.tags = tag_list::TagList::with_repo_name(ui.repo.get());
}
//setup tui
let stdout = io::stdout().into_raw_mode().unwrap();
let backend = TermionBackend::new(stdout);
let mut terminal = Terminal::new(backend).unwrap();
//setup input thread
let receiver = super::spawn_stdin_channel();
//core interaction loop
'core: loop {
//draw
terminal
.draw(|rect| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3),
Constraint::Min(7),
Constraint::Length(2),
]
.as_ref(),
)
.split(rect.size());
rect.render_widget(ui.repo.render(ui.state == State::EditRepo), chunks[0]);
let (list, state) = ui.tags.render(ui.state == State::SelectTag);
let more_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(15), Constraint::Length(28)].as_ref())
.split(chunks[1]);
rect.render_stateful_widget(list, more_chunks[0], state);
rect.render_widget(ui.details.render(), more_chunks[1]);
rect.render_widget(ui.info.render(), chunks[2]);
})
.unwrap();
//handle input
match receiver.try_recv() {
Ok(Key::Ctrl('q')) => break 'core,
Ok(Key::Char('\t')) => {
ui.state.next();
ui.info.set_info(&ui.state);
}
Ok(Key::Ctrl('r')) => {
ui.repo.confirm();
ui.tags = tag_list::TagList::with_repo_name(ui.repo.get());
}
Ok(Key::Char('\n')) => match ui.state {
State::EditRepo => {
ui.repo.confirm();
ui.tags = tag_list::TagList::with_repo_name(ui.repo.get());
}
State::SelectTag => ui.tags.handle_input(Key::Char('\n')),
},
Ok(Key::Char(key)) => match ui.state {
State::EditRepo => {
ui.info.set_text("Editing Repository");
ui.repo.handle_input(Key::Char(key));
}
State::SelectTag => {
ui.tags.handle_input(Key::Char(key));
}
},
Ok(Key::Backspace) => match ui.state {
State::EditRepo => {
ui.info.set_text("Editing Repository");
ui.repo.handle_input(Key::Backspace);
}
State::SelectTag => (),
},
Ok(Key::Up) => match ui.state {
State::EditRepo => (),
State::SelectTag => {
ui.tags.handle_input(Key::Up);
ui.details = ui.tags.create_detail_widget();
}
},
Ok(Key::Down) => match ui.state {
State::EditRepo => (),
State::SelectTag => {
ui.tags.handle_input(Key::Down);
ui.details = ui.tags.create_detail_widget();
}
},
_ => (),
}
//sleep for 32ms (30 fps)
thread::sleep(std::time::Duration::from_millis(32));
}
terminal.clear().unwrap();
}
}

61
src/widget/details.rs Normal file
View File

@ -0,0 +1,61 @@
use tui::style::{Color, Style};
use tui::widgets::{Block, Borders, List};
use crate::repository;
pub struct Details {
details: Vec<repository::TagDetails>,
}
impl Details {
pub fn new() -> Self {
Self { details: vec![] }
}
pub fn with_list(details: &[crate::repository::TagDetails]) -> Self {
let mut detail = Self {
details: details.to_owned(),
};
detail.details.sort_by(|a, b| a.arch.cmp(&b.arch));
detail.details.dedup();
detail
}
pub fn get_details(&self) -> Vec<String> {
let mut lines = vec![format!("{:^10}|{:^6}|{:^6}", "ARCH", "OS", "SIZE")];
for d in &self.details {
lines.push(format!(
"{:^10}|{:^6}|{:^6}MB",
format!(
"{}{}",
d.arch.clone().unwrap_or_default(),
d.variant.clone().unwrap_or_default()
),
d.os.clone().unwrap_or_default(),
d.size.unwrap_or_default() / 1024 / 1024,
));
}
lines
}
pub fn render(&self) -> List {
let items: Vec<tui::widgets::ListItem> = self
.get_details()
.iter()
.map(|l| {
tui::widgets::ListItem::new(l.to_string())
.style(Style::default().fg(Color::White).bg(Color::Black))
})
.collect();
List::new(items)
.block(
Block::default()
.title("Details")
.borders(Borders::ALL)
.border_style(Style::default()),
)
.style(Style::default().fg(Color::White).bg(Color::Black))
}
}

View File

@ -11,7 +11,7 @@ impl Info {
Self {
info: String::from(info),
keys: String::from(
"Tab Cycle widgets C-s Save C-r Reload C-q Quit C-n Next page C-p Previous page ↑ ↓ Select tags or image line",
"Tab Cycle widgets C-s Save C-r Reload C-q Quit ↑ ↓ Select tags or image line Return Select current selection",
),
}
}
@ -27,7 +27,13 @@ impl Info {
.highlight_style(Style::default().bg(Color::Black))
}
pub fn set_info(&mut self, info: &str) {
/// set a text to display
pub fn set_text(&mut self, info: &str) {
self.info = String::from(info);
}
/// print a text to display
pub fn set_info(&mut self, text: &dyn std::fmt::Display) {
self.info = format!("{}", text);
}
}

View File

@ -1,3 +1,4 @@
pub mod details;
pub mod info;
pub mod repo_entry;
pub mod service_switcher;

View File

@ -3,20 +3,21 @@ use tui::layout::Alignment;
use tui::style::{Color, Style};
use tui::widgets::{Block, Borders, Paragraph};
use crate::ui::State;
pub struct RepoEntry {
text: String,
old_text: String,
changed: bool,
default_text: bool,
}
impl RepoEntry {
pub fn new(text: &str) -> Self {
pub fn new(text: Option<&str>) -> Self {
let default_text = "enter a repository here or select one from file widget";
Self {
text: String::from(text),
old_text: String::from(text),
text: String::from(text.unwrap_or(default_text)),
old_text: String::from(text.unwrap_or(default_text)),
changed: false,
default_text: text.is_none(),
}
}
@ -29,13 +30,13 @@ impl RepoEntry {
self.old_text = entry;
}
pub fn render(&self, state: &crate::ui::State) -> Paragraph {
pub fn render(&self, colored: bool) -> Paragraph {
let title = match self.changed {
true => "Repository*",
false => "Repository",
};
let border_style = if state == &crate::ui::State::EditRepo {
let border_style = if colored {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Gray)
@ -52,19 +53,20 @@ impl RepoEntry {
.alignment(Alignment::Left)
}
pub fn handle_input(&mut self, state: &State, key: termion::event::Key) {
if state != &State::EditRepo {
return;
}
pub fn handle_input(&mut self, key: termion::event::Key) {
match key {
// Key::Char('\n') => self.confirm(), //handled in Ui
Key::Char(c) => {
self.text.push(c);
self.changed = true;
self.default_text = false;
}
Key::Backspace => {
self.text.pop();
if self.default_text {
self.text = String::new();
} else {
self.text.pop();
}
self.changed = true;
}
Key::Esc => {

View File

@ -3,12 +3,12 @@ use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Write;
use std::path::PathBuf;
use tui::style::{Color, Style};
use tui::widgets::{Block, Borders, List, ListState};
use crate::repo;
use crate::ui::State;
#[derive(Debug)]
pub enum Error {
@ -25,19 +25,28 @@ impl fmt::Display for Error {
}
}
pub struct ServiceSwitcher<'a> {
pub struct ServiceSwitcher {
list: Vec<String>,
state: ListState,
changed: bool,
opened_file: &'a str,
opened_file: PathBuf,
}
impl ServiceSwitcher<'_> {
pub fn new() -> Self {
let file_list = vec!["docker-compose.yml", "docker-compose.yaml"];
impl ServiceSwitcher {
pub fn new(file: &Option<PathBuf>) -> Option<Self> {
//gather possible filenames
let mut file_list = vec![
PathBuf::from("docker-compose.yml"),
PathBuf::from("docker-compose.yaml"),
];
match &file {
None => (),
Some(file) => file_list.insert(0, file.clone()),
}
//try filenames
for file in file_list {
let list = match File::open(file) {
let list = match File::open(&file) {
Err(_) => continue,
Ok(file) => {
let buf = BufReader::new(file);
@ -47,33 +56,28 @@ impl ServiceSwitcher<'_> {
}
};
return Self {
return Some(Self {
list,
state: ListState::default(),
changed: false,
opened_file: file,
};
});
}
//could not find docker-compose file
Self {
list: vec![format!("No docker-compose file found")],
state: ListState::default(),
changed: false,
opened_file: "No file",
}
None
}
pub fn render(&mut self, state: &State) -> (List, &mut ListState) {
let border_style = if state == &State::SelectService {
pub fn render(&mut self, colored: bool) -> (List, &mut ListState) {
let border_style = if colored {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Gray)
};
let title = match &self.changed {
true => format!("File: *{}*", self.opened_file),
false => format!("File: {}", self.opened_file),
true => format!("File: *{}*", &self.opened_file.display()),
false => format!("File: {}", &self.opened_file.display()),
};
let items: Vec<tui::widgets::ListItem> = self
@ -102,10 +106,7 @@ impl ServiceSwitcher<'_> {
/// finds the next image tag in given file
pub fn find_next_match(&mut self) -> bool {
let current_line: usize = match self.state.selected() {
None => 0,
Some(i) => i,
};
let current_line: usize = self.state.selected().unwrap_or(0);
let mut i = (current_line + 1) % self.list.len();
loop {
@ -115,7 +116,7 @@ impl ServiceSwitcher<'_> {
}
//check if line matches
if repo::match_yaml_image(&self.list[i]).is_some() {
if repo::match_yaml_image(&self.list[i]).is_ok() {
self.state.select(Some(i));
return true;
}
@ -129,10 +130,7 @@ impl ServiceSwitcher<'_> {
/// finds the previous image tag in given file
pub fn find_previous_match(&mut self) -> bool {
let current_line: usize = match self.state.selected() {
None => 0,
Some(i) => i,
};
let current_line: usize = self.state.selected().unwrap_or(0);
let mut i: usize = if current_line == 0 {
self.list.len() - 1
@ -147,7 +145,7 @@ impl ServiceSwitcher<'_> {
}
//check if line matches
if repo::match_yaml_image(&self.list[i]).is_some() {
if repo::match_yaml_image(&self.list[i]).is_ok() {
self.state.select(Some(i));
return true;
}
@ -163,10 +161,10 @@ impl ServiceSwitcher<'_> {
/// return the repository from currently selected row
pub fn extract_repo(&self) -> Result<String, Error> {
match self.state.selected() {
None => return Err(Error::NoneSelected),
None => Err(Error::NoneSelected),
Some(i) => match repo::match_yaml_image(&self.list[i]) {
None => return Err(Error::Parsing(String::from("Nothing found"))),
Some((_, repo)) => return Ok(repo.to_string()),
Err(_) => Err(Error::Parsing(String::from("Nothing found"))),
Ok((_, repo)) => Ok(repo.to_string()),
},
}
}
@ -176,8 +174,8 @@ impl ServiceSwitcher<'_> {
match self.state.selected() {
None => (),
Some(i) => match repo::match_yaml_image(&self.list[i]) {
None => return,
Some((front, _)) => self.list[i] = format!("{}{}", front, repo_with_tag),
Err(_) => return,
Ok((front, _)) => self.list[i] = format!("{}{}", front, repo_with_tag),
},
}
self.changed = true;
@ -185,7 +183,7 @@ impl ServiceSwitcher<'_> {
/// save the currently opened file
pub fn save(&mut self) -> Result<(), std::io::Error> {
let mut file = File::create(self.opened_file)?;
let mut file = File::create(&self.opened_file)?;
for line in &self.list {
file.write_all(line.as_bytes())?;
file.write_all("\n".as_bytes())?;

View File

@ -4,127 +4,99 @@ use termion::event::Key;
use tui::style::{Color, Style};
use tui::widgets::{Block, Borders, List, ListState};
use crate::tags;
use crate::ui::State;
use crate::repository;
#[derive(Debug)]
pub enum Error {
NoneSelected,
NoTags,
NoNextPage,
NoPrevPage,
NextPageSelected,
SelectedStatus,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::NoTags => write!(f, "There are no tags"),
Error::NoneSelected => write!(f, "No tag selected"),
Error::NoNextPage => write!(f, "No next page available"),
Error::NoPrevPage => write!(f, "No previous page available"),
Error::NextPageSelected => write!(f, "tried to get the next page"),
Error::SelectedStatus => write!(f, "Status message was selected"),
}
}
}
/// used for creating a TagList
pub enum Type {
enum Line {
Status(String),
Repo(tags::Tags),
Image(repository::Tag),
NextPage(String),
}
impl fmt::Display for Line {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Line::Status(s) => write!(f, "{}", s),
Line::Image(i) => write!(f, "{}", i.get_name_with_details()),
Line::NextPage(s) => write!(f, "{}", s),
}
}
}
pub struct TagList {
typ: Type,
lines: Vec<Line>,
state: ListState,
tags: Option<repository::Repo>,
}
impl TagList {
fn new(typ: Type) -> Self {
Self {
typ,
state: ListState::default(),
}
}
/// create a TagList with a status message
/// shows a text in the list and no tags
pub fn with_status(status: &str) -> Self {
Self::new(Type::Status(String::from(status)))
}
/// create a TagList
pub fn with_repo(name: String) -> Self {
match tags::Tags::new(name) {
Err(e) => Self::with_status(&format!("{}", e)),
Ok(tags) => Self::new(Type::Repo(tags)),
Self {
lines: vec![Line::Status(String::from(status))],
state: ListState::default(),
tags: None,
}
}
/// display next page if possible
pub fn next_page(&mut self) -> Result<(), Error> {
match &self.typ {
Type::Status(_) => (),
Type::Repo(tags) => match tags.next_page() {
Err(_) => return Err(Error::NoNextPage),
Ok(tags) => self.typ = Type::Repo(tags),
},
}
Ok(())
}
/// display previous page if possible
pub fn prev_page(&mut self) -> Result<(), Error> {
match &self.typ {
Type::Status(_) => (),
Type::Repo(tags) => match tags.prev_page() {
Err(_) => return Err(Error::NoPrevPage),
Ok(tags) => self.typ = Type::Repo(tags),
},
}
Ok(())
}
/// get a list of tag names with info
fn print_lines(&self) -> Vec<String> {
match &self.typ {
Type::Status(line) => vec![line.to_string()],
Type::Repo(tags) => tags.results.iter().map(|r| format!("{}", r)).collect(),
/// list the tags of the repository if the input is valid
pub fn with_repo_name(repo: String) -> Self {
match repository::Repo::new(&repo) {
Ok(tags) => Self::with_tags(tags),
Err(_) => Self::with_status("input repo was not found"),
}
}
/// get the list of tag names
pub fn get_names(&self) -> Result<Vec<String>, Error> {
match &self.typ {
Type::Status(_) => Err(Error::NoTags),
Type::Repo(tags) => Ok(tags.results.iter().map(|r| r.tag_name.clone()).collect()),
/// list the tags of the input
fn with_tags(mut tags: repository::Repo) -> Self {
let mut lines: Vec<Line> = tags
.get_tags()
.iter()
.map(|r| Line::Image(r.clone()))
.collect();
match tags.next_page() {
None => (),
Some(new_tags) => {
lines.push(Line::NextPage(String::from("load more tags")));
tags = new_tags;
}
};
Self {
lines,
state: ListState::default(),
tags: Some(tags),
}
}
/// get the selected tag or return an error
pub fn get_selected(&self) -> Result<String, Error> {
match &self.typ {
Type::Status(_) => Err(Error::NoTags),
Type::Repo(_) => match self.state.selected() {
None => Err(Error::NoneSelected),
Some(i) => Ok(self.get_names().unwrap()[i].clone()),
},
}
}
pub fn render(&mut self, state: &State) -> (List, &mut ListState) {
let border_style = if state == &State::SelectTag {
pub fn render(&mut self, colored: bool) -> (List, &mut ListState) {
let border_style = if colored {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Gray)
};
let lines = match &self.typ {
Type::Status(line) => vec![line.clone()],
Type::Repo(_) => self.print_lines(),
};
let items: Vec<tui::widgets::ListItem> = lines
let items: Vec<tui::widgets::ListItem> = self
.lines
.iter()
.map(|l| {
tui::widgets::ListItem::new(l.clone())
tui::widgets::ListItem::new(format!("{}", l))
.style(Style::default().fg(Color::White).bg(Color::Black))
})
.collect();
@ -144,36 +116,99 @@ impl TagList {
(items, &mut self.state)
}
pub fn handle_input(&mut self, state: &State, key: termion::event::Key) {
if state != &State::SelectTag {
return;
}
pub fn create_detail_widget(&self) -> crate::widget::details::Details {
use crate::widget::details::Details;
match self.state.selected() {
None => Details::new(),
Some(i) => match &self.lines[i] {
Line::Image(t) => Details::with_list(t.get_details()),
_ => Details::new(),
},
}
}
pub fn handle_input(&mut self, key: termion::event::Key) {
match key {
Key::Down => self.next(),
Key::Up => self.previous(),
Key::Char('\n') => self.select(),
_ => (),
}
}
/// select next tag
pub fn next(&mut self) {
/// loads new tags when matching line is selected
fn select(&mut self) {
if let Some(i) = self.state.selected() {
if let Line::NextPage(_) = &self.lines[i] {
self.load_next_page()
}
}
}
pub fn get_selected(&mut self) -> Result<String, Error> {
match self.state.selected() {
None if self.print_lines().len() > 0 => self.state.select(Some(0)),
None => Err(Error::NoneSelected),
Some(i) => match &self.lines[i] {
Line::Status(_) => Err(Error::SelectedStatus),
Line::Image(i) => Ok(i.get_name().to_string()),
Line::NextPage(_) => {
self.load_next_page();
Err(Error::NextPageSelected)
}
},
}
}
/// load new tags from the next page
fn load_next_page(&mut self) {
match &self.tags {
Some(tags) => match tags.next_page() {
None => (),
Some(new_tags) => {
//load new tags object
self.tags = Some(new_tags);
//remove "load next page"
let next_page = self.lines.pop();
//add tags
match &self.tags {
None => (),
Some(tags) => {
for image in tags.get_tags().iter() {
self.lines.push(Line::Image(image.clone()));
}
}
}
//readd next page
match self.tags.as_ref().unwrap().next_page() {
None => (),
Some(_) => self.lines.push(next_page.unwrap()),
}
}
},
None => (),
Some(i) if i == self.print_lines().len() - 1 => self.state.select(Some(0)),
}
}
/// select next tag
fn next(&mut self) {
match self.state.selected() {
None if !self.lines.is_empty() => self.state.select(Some(0)),
None => (),
Some(i) if i == self.lines.len() - 1 => self.state.select(Some(0)),
Some(i) => self.state.select(Some(i + 1)),
}
}
/// select previous tag
pub fn previous(&mut self) {
fn previous(&mut self) {
match self.state.selected() {
None if self.print_lines().len() > 0 => {
self.state.select(Some(self.print_lines().len()))
}
None if !self.lines.is_empty() => self.state.select(Some(self.lines.len())),
None => (),
Some(i) if i == 0 => self.state.select(Some(self.print_lines().len() - 1)),
Some(i) if i == 0 => self.state.select(Some(self.lines.len() - 1)),
Some(i) => self.state.select(Some(i - 1)),
}
}