Add `radicle-cli` crate's `terminal` module

Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
Co-authored-by: xphoniex <dj.2dixx@gmail.com>
This commit is contained in:
Alexis Sellier 2022-11-07 13:38:16 +01:00
parent 357fc79b41
commit 223d36086e
No known key found for this signature in database
15 changed files with 1215 additions and 3 deletions

76
Cargo.lock generated
View File

@ -279,6 +279,20 @@ dependencies = [
"winapi",
]
[[package]]
name = "console"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c050367d967ced717c04b65d8c619d863ef9292ce0c5760028655a2fb298718c"
dependencies = [
"encode_unicode",
"lazy_static",
"libc",
"terminal_size",
"unicode-width",
"winapi",
]
[[package]]
name = "const-oid"
version = "0.9.0"
@ -459,6 +473,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "dialoguer"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92e7e37ecef6857fdc0c0c5d42fd5b0938e46590c2183cc92dd310a6d078eb1"
dependencies = [
"console",
"tempfile",
"zeroize",
]
[[package]]
name = "digest"
version = "0.9.0"
@ -544,6 +569,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "encode_unicode"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
[[package]]
name = "fastrand"
version = "1.8.0"
@ -893,6 +924,18 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "indicatif"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d207dc617c7a380ab07ff572a6e52fa202a2a8f355860ac9c38e23f8196be1b"
dependencies = [
"console",
"lazy_static",
"number_prefix",
"regex",
]
[[package]]
name = "inout"
version = "0.1.3"
@ -1189,6 +1232,12 @@ dependencies = [
"libc",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "olpc-cjson"
version = "0.1.2"
@ -1442,6 +1491,23 @@ dependencies = [
"zeroize",
]
[[package]]
name = "radicle-cli"
version = "0.8.0"
dependencies = [
"anyhow",
"console",
"dialoguer",
"indicatif",
"lexopt",
"log",
"radicle",
"radicle-cob",
"radicle-crypto",
"thiserror",
"zeroize",
]
[[package]]
name = "radicle-cob"
version = "0.1.0"
@ -2013,6 +2079,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "terminal_size"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "thiserror"
version = "1.0.37"

View File

@ -2,6 +2,7 @@
members = [
"radicle",
"radicle-cob",
"radicle-cli",
"radicle-crypto",
"radicle-httpd",
"radicle-node",

28
radicle-cli/Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "radicle-cli"
license = "MIT OR Apache-2.0"
version = "0.8.0"
authors = ["Alexis Sellier <alexis@radicle.xyz>"]
edition = "2021"
[dependencies]
anyhow = { version = "1" }
console = { version = "0.15" }
dialoguer = { version = "0.10.0" }
indicatif = { version = "0.16.2" }
lexopt = { version = "0.2" }
log = { version = "0.4", features = ["std"] }
thiserror = { version = "1" }
zeroize = { version = "1.1" }
[dependencies.radicle]
version = "0"
path = "../radicle"
[dependencies.radicle-cob]
version = "0"
path = "../radicle-cob"
[dependencies.radicle-crypto]
version = "0"
path = "../radicle-crypto"

2
radicle-cli/src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
#![allow(clippy::collapsible_if)]
pub mod terminal;

174
radicle-cli/src/terminal.rs Normal file
View File

@ -0,0 +1,174 @@
pub mod args;
pub mod command;
pub mod format;
pub mod io;
pub mod patch;
pub mod spinner;
pub mod table;
pub mod textbox;
use std::ffi::OsString;
use std::process;
use dialoguer::console::style;
use radicle::profile::Profile;
pub use args::{Args, Error, Help};
pub use console::measure_text_width as text_width;
pub use dialoguer::Editor;
pub use io::*;
pub use spinner::{spinner, Spinner};
pub use table::Table;
pub use textbox::TextBox;
/// Context passed to all commands.
pub trait Context {
/// Return the currently active profile, or an error if no profile is active.
fn profile(&self) -> Result<Profile, anyhow::Error>;
}
impl Context for Profile {
fn profile(&self) -> Result<Profile, anyhow::Error> {
Ok(self.clone())
}
}
impl<F> Context for F
where
F: Fn() -> Result<Profile, anyhow::Error>,
{
fn profile(&self) -> Result<Profile, anyhow::Error> {
self()
}
}
/// A command that can be run.
pub trait Command<A: Args, C: Context> {
/// Run the command, given arguments and a context.
fn run(self, args: A, context: C) -> anyhow::Result<()>;
}
impl<F, A: Args, C: Context> Command<A, C> for F
where
F: FnOnce(A, C) -> anyhow::Result<()>,
{
fn run(self, args: A, context: C) -> anyhow::Result<()> {
self(args, context)
}
}
pub fn run_command<A, C>(help: Help, action: &str, cmd: C) -> !
where
A: Args,
C: Command<A, fn() -> anyhow::Result<Profile>>,
{
let args = std::env::args_os().into_iter().skip(1).collect();
run_command_args(help, action, cmd, args)
}
pub fn run_command_args<A, C>(help: Help, action: &str, cmd: C, args: Vec<OsString>) -> !
where
A: Args,
C: Command<A, fn() -> anyhow::Result<Profile>>,
{
use io as term;
let options = match A::from_args(args) {
Ok((opts, unparsed)) => {
if let Err(err) = args::finish(unparsed) {
term::error(err);
process::exit(1);
}
opts
}
Err(err) => {
match err.downcast_ref::<Error>() {
Some(Error::Help) => {
term::help(help.name, help.version, help.description, help.usage);
process::exit(0);
}
Some(Error::Usage) => {
term::usage(help.name, help.usage);
process::exit(1);
}
_ => {}
};
eprintln!(
"{} {} {} {}",
style("==").red(),
style("Error:").red(),
style(format!("rad-{}:", help.name)).red(),
style(&err).red()
);
if let Some(Error::WithHint { hint, .. }) = err.downcast_ref::<Error>() {
eprintln!("{}", style(hint).yellow());
}
process::exit(1);
}
};
match cmd.run(options, self::profile) {
Ok(()) => process::exit(0),
Err(err) => {
term::fail(&format!("{} failed", action), &err);
process::exit(1);
}
}
}
/// Get the default profile. Fails if there is no profile.
fn profile() -> Result<Profile, anyhow::Error> {
let error = args::Error::WithHint {
err: anyhow::anyhow!("Could not load radicle profile"),
hint: "To setup your radicle profile, run `rad auth`.",
};
match Profile::load() {
Ok(profile) => Ok(profile),
Err(_) => Err(error.into()),
}
}
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub enum Interactive {
Yes,
No,
}
impl Default for Interactive {
fn default() -> Self {
Interactive::No
}
}
impl Interactive {
pub fn yes(&self) -> bool {
(*self).into()
}
pub fn no(&self) -> bool {
!self.yes()
}
}
impl From<Interactive> for bool {
fn from(c: Interactive) -> Self {
match c {
Interactive::Yes => true,
Interactive::No => false,
}
}
}
impl From<bool> for Interactive {
fn from(b: bool) -> Self {
if b {
Interactive::Yes
} else {
Interactive::No
}
}
}

View File

@ -0,0 +1,73 @@
use std::ffi::OsString;
use std::str::FromStr;
use anyhow::anyhow;
#[derive(thiserror::Error, Debug)]
pub enum Error {
/// If this error is returned from argument parsing, help is displayed.
#[error("help invoked")]
Help,
/// If this error is returned from argument parsing, usage is displayed.
#[error("usage invoked")]
Usage,
/// An error with a hint.
#[error("{err}")]
WithHint {
err: anyhow::Error,
hint: &'static str,
},
}
pub struct Help {
pub name: &'static str,
pub description: &'static str,
pub version: &'static str,
pub usage: &'static str,
}
pub trait Args: Sized {
fn from_env() -> anyhow::Result<Self> {
let args: Vec<_> = std::env::args_os().into_iter().skip(1).collect();
match Self::from_args(args) {
Ok((opts, unparsed)) => {
self::finish(unparsed)?;
Ok(opts)
}
Err(err) => Err(err),
}
}
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)>;
}
pub fn parse_value<T: FromStr>(flag: &str, value: OsString) -> anyhow::Result<T>
where
<T as FromStr>::Err: std::error::Error,
{
value
.into_string()
.map_err(|_| anyhow!("the value specified for '--{}' is not valid unicode", flag))?
.parse()
.map_err(|e| anyhow!("invalid value specified for '--{}' ({})", flag, e))
}
pub fn format(arg: lexopt::Arg) -> OsString {
match arg {
lexopt::Arg::Long(flag) => format!("--{}", flag).into(),
lexopt::Arg::Short(flag) => format!("-{}", flag).into(),
lexopt::Arg::Value(val) => val,
}
}
pub fn finish(unparsed: Vec<OsString>) -> anyhow::Result<()> {
if let Some(arg) = unparsed.first() {
return Err(anyhow::anyhow!(
"unexpected argument `{}`",
arg.to_string_lossy()
));
}
Ok(())
}

View File

@ -0,0 +1,19 @@
use std::io::Write;
use std::process::{Command, Stdio};
pub fn bat<S: AsRef<std::ffi::OsStr>>(
args: impl IntoIterator<Item = S>,
stdin: &str,
) -> anyhow::Result<()> {
let mut child = Command::new("bat")
.stdin(Stdio::piped())
.args(args)
.spawn()?;
let writer = child.stdin.as_mut().unwrap();
writer.write_all(stdin.as_bytes())?;
child.wait()?;
Ok(())
}

View File

@ -0,0 +1,138 @@
use std::fmt;
use dialoguer::console::style;
use radicle::node::NodeId;
use radicle::profile::Profile;
use radicle_cob::ObjectId;
use crate::terminal as term;
/// Format a node id to be more compact.
pub fn node(node: &NodeId) -> String {
let node = node.to_human();
let start = node.chars().take(7).collect::<String>();
let end = node.chars().skip(node.len() - 7).collect::<String>();
format!("{}{}", start, end)
}
/// Format a git Oid.
pub fn oid(oid: &radicle::git::Oid) -> String {
format!("{:.7}", oid)
}
/// Format a COB id.
pub fn cob(id: &ObjectId) -> String {
format!("{:.11}", id.to_string())
}
/// Identity formatter that takes a profile and displays it as
/// `<node-id> (<username>)` depending on the configuration.
pub struct Identity<'a> {
profile: &'a Profile,
/// If true, node id is printed in its compact form.
short: bool,
/// If true, node id and username are printed using the terminal's
/// styled formatters.
styled: bool,
}
impl<'a> Identity<'a> {
pub fn new(profile: &'a Profile) -> Self {
Self {
profile,
short: false,
styled: false,
}
}
pub fn short(mut self) -> Self {
self.short = true;
self
}
pub fn styled(mut self) -> Self {
self.styled = true;
self
}
}
impl<'a> fmt::Display for Identity<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let username = "(me)";
let node_id = match self.short {
true => self::node(self.profile.id()),
false => self.profile.id().to_human(),
};
if self.styled {
write!(
f,
"{} {}",
term::format::highlight(node_id),
term::format::dim(username)
)
} else {
write!(f, "{} {}", node_id, username)
}
}
}
pub fn negative<D: std::fmt::Display>(msg: D) -> String {
style(msg).red().bright().to_string()
}
pub fn positive<D: std::fmt::Display>(msg: D) -> String {
style(msg).green().bright().to_string()
}
pub fn secondary<D: std::fmt::Display>(msg: D) -> String {
style(msg).blue().bright().to_string()
}
pub fn tertiary<D: std::fmt::Display>(msg: D) -> String {
style(msg).cyan().to_string()
}
pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> String {
style(msg).cyan().bold().to_string()
}
pub fn yellow<D: std::fmt::Display>(msg: D) -> String {
style(msg).yellow().to_string()
}
pub fn highlight<D: std::fmt::Display>(input: D) -> String {
style(input).green().bright().to_string()
}
pub fn badge_primary<D: std::fmt::Display>(input: D) -> String {
style(format!(" {} ", input))
.magenta()
.reverse()
.to_string()
}
pub fn badge_positive<D: std::fmt::Display>(input: D) -> String {
style(format!(" {} ", input)).green().reverse().to_string()
}
pub fn badge_negative<D: std::fmt::Display>(input: D) -> String {
style(format!(" {} ", input)).red().reverse().to_string()
}
pub fn badge_secondary<D: std::fmt::Display>(input: D) -> String {
style(format!(" {} ", input)).blue().reverse().to_string()
}
pub fn bold<D: std::fmt::Display>(input: D) -> String {
style(input).white().bright().bold().to_string()
}
pub fn dim<D: std::fmt::Display>(input: D) -> String {
style(input).dim().to_string()
}
pub fn italic<D: std::fmt::Display>(input: D) -> String {
style(input).italic().dim().to_string()
}

View File

@ -0,0 +1,382 @@
use std::fmt;
use std::str::FromStr;
use radicle::crypto::ssh::keystore::Passphrase;
use radicle::crypto::Signer;
use radicle::profile::env::RAD_PASSPHRASE;
use radicle::profile::Profile;
use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password};
use radicle_crypto::ssh::keystore::MemorySigner;
use super::command;
use super::format;
use super::spinner::spinner;
use super::Error;
pub const TAB: &str = " ";
#[macro_export]
macro_rules! info {
($($arg:tt)*) => ({
println!("{}", format_args!($($arg)*));
})
}
#[macro_export]
macro_rules! success {
($($arg:tt)*) => ({
$crate::terminal::io::success_args(format_args!($($arg)*));
})
}
#[macro_export]
macro_rules! tip {
($($arg:tt)*) => ({
$crate::io::tip_args(format_args!($($arg)*));
})
}
pub use info;
pub use success;
pub use tip;
pub fn success_args(args: fmt::Arguments) {
println!("{} {}", style("ok").green().reverse(), args);
}
pub fn tip_args(args: fmt::Arguments) {
println!(
"{} {}",
style("=>").blue(),
style(format!("{}", args)).dim()
);
}
pub fn width() -> usize {
let (_, rows) = console::Term::stdout().size();
rows as usize
}
pub fn headline(headline: &str) {
println!();
println!("{}", style(headline).bold());
println!();
}
pub fn blob(text: impl fmt::Display) {
println!("{}", style(text.to_string().trim()).dim());
}
pub fn blank() {
println!()
}
pub fn print(msg: impl fmt::Display) {
println!("{}", msg);
}
pub fn prefixed(prefix: &str, text: &str) -> String {
text.split('\n')
.map(|line| format!("{}{}\n", prefix, line))
.collect()
}
pub fn help(name: &str, version: &str, description: &str, usage: &str) {
println!("rad-{} {}\n{}\n{}", name, version, description, usage);
}
pub fn usage(name: &str, usage: &str) {
eprintln!(
"{} {}\n{}",
style("==").red(),
style(format!("Error: rad-{}: invalid usage", name)).red(),
style(prefixed(TAB, usage)).red().dim()
);
}
pub fn eprintln(prefix: impl fmt::Display, msg: impl fmt::Display) {
eprintln!("{} {}", prefix, msg);
}
pub fn indented(msg: &str) {
println!("{}{}", TAB, msg);
}
pub fn subcommand(msg: impl fmt::Display) {
println!("{} {}", style("$").dim(), style(msg).dim());
}
pub fn warning(warning: &str) {
eprintln!(
"{} {} {}",
style("**").yellow(),
style("Warning:").yellow().bold(),
style(warning).yellow()
);
}
pub fn error(error: impl fmt::Display) {
eprintln!("{} {}", style("==").red(), style(error).red());
}
pub fn fail(header: &str, error: &anyhow::Error) {
let err = error.to_string();
let err = err.trim_end();
let separator = if err.len() > 160 || err.contains('\n') {
"\n"
} else {
" "
};
eprintln!(
"{} {}{}{}",
style("==").red(),
style(header).red().reverse(),
separator,
style(error).red().bold(),
);
let cause = error.root_cause();
if cause.to_string() != error.to_string() {
eprintln!(
"{} {}",
style("==").red().dim(),
style(error.root_cause()).red().dim()
);
blank();
}
if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::<Error>() {
eprintln!("{} {}", style("==").yellow(), style(hint).yellow(),);
blank();
}
}
pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
dialoguer::Confirm::new()
.with_prompt(format!("{} {}", style("".to_owned()).cyan(), prompt))
.wait_for_newline(false)
.default(true)
.default(default)
.interact()
.unwrap_or_default()
}
pub fn confirm<D: fmt::Display>(prompt: D) -> bool {
ask(format::tertiary(prompt), true)
}
pub fn abort<D: fmt::Display>(prompt: D) -> bool {
ask(format::tertiary(prompt), false)
}
/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user.
pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
let signer = if let Ok(signer) = profile.signer() {
signer.boxed()
} else {
let passphrase = secret_input();
let spinner = spinner("Unsealing key...");
let signer = MemorySigner::load(&profile.keystore, passphrase)?;
spinner.finish();
signer.boxed()
};
Ok(signer)
}
pub fn theme() -> ColorfulTheme {
ColorfulTheme {
success_prefix: style("ok".to_owned()).for_stderr().green().reverse(),
prompt_prefix: style("".to_owned()).cyan().dim().for_stderr(),
prompt_suffix: style("·".to_owned()).cyan().for_stderr(),
prompt_style: Style::new().cyan().bold().for_stderr(),
active_item_style: Style::new().for_stderr().yellow().reverse(),
active_item_prefix: style("*".to_owned()).yellow().for_stderr(),
picked_item_prefix: style("*".to_owned()).yellow().for_stderr(),
inactive_item_prefix: style(" ".to_string()).for_stderr(),
inactive_item_style: Style::new().yellow().for_stderr(),
error_prefix: style("⤹ Error:".to_owned()).red().for_stderr(),
success_suffix: style("·".to_owned()).cyan().for_stderr(),
..ColorfulTheme::default()
}
}
pub fn text_input<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
where
S: fmt::Display + std::str::FromStr<Err = E> + Clone,
E: fmt::Debug + fmt::Display,
{
let theme = theme();
let mut input: Input<S> = Input::with_theme(&theme);
let value = match default {
Some(default) => input
.with_prompt(message)
.with_initial_text(default.to_string())
.interact_text()?,
None => input.with_prompt(message).interact_text()?,
};
Ok(value)
}
#[derive(Debug, Default, Clone)]
pub struct Optional<T> {
option: Option<T>,
}
impl<T: fmt::Display> fmt::Display for Optional<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(val) = &self.option {
write!(f, "{}", val)
} else {
write!(f, "")
}
}
}
impl<T: FromStr> FromStr for Optional<T> {
type Err = <T as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
return Ok(Optional { option: None });
}
let val: T = s.parse()?;
Ok(Self { option: Some(val) })
}
}
pub fn text_input_optional<S, E>(
message: &str,
initial: Option<String>,
) -> anyhow::Result<Option<S>>
where
S: fmt::Display + fmt::Debug + FromStr<Err = E> + Clone,
E: fmt::Debug + fmt::Display,
{
let theme = theme();
let mut input: Input<Optional<S>> = Input::with_theme(&theme);
if let Some(init) = initial {
input.with_initial_text(init);
}
let value = input
.with_prompt(message)
.allow_empty(true)
.interact_text()?;
Ok(value.option)
}
pub fn secret_input() -> Passphrase {
secret_input_with_prompt("Passphrase")
}
// TODO: This prompt shows success just for entering a password,
// even if the password is later found out to be wrong.
// We should handle this differently.
pub fn secret_input_with_prompt(prompt: &str) -> Passphrase {
Passphrase::from(
Password::with_theme(&theme())
.allow_empty_password(true)
.with_prompt(prompt)
.interact()
.unwrap(),
)
}
pub fn secret_input_with_confirmation() -> Passphrase {
Passphrase::from(
Password::with_theme(&theme())
.with_prompt("Passphrase")
.with_confirmation("Repeat passphrase", "Error: the passphrases don't match.")
.interact()
.unwrap(),
)
}
pub fn secret_stdin() -> Result<Passphrase, anyhow::Error> {
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(Passphrase::from(input.trim_end().to_owned()))
}
pub fn read_passphrase(stdin: bool, confirm: bool) -> Result<Passphrase, anyhow::Error> {
let passphrase = match read_passphrase_from_env_var() {
Ok(input) => input,
_ => {
if stdin {
secret_stdin()?
} else if confirm {
secret_input_with_confirmation()
} else {
secret_input()
}
}
};
Ok(passphrase)
}
pub fn read_passphrase_from_env_var() -> Result<Passphrase, anyhow::Error> {
let passphrase = std::env::var(RAD_PASSPHRASE)?;
Ok(Passphrase::from(passphrase.trim_end().to_owned()))
}
pub fn select<'a, T>(options: &'a [T], active: &'a T) -> Option<&'a T>
where
T: fmt::Display + Eq + PartialEq,
{
let theme = theme();
let active = options.iter().position(|o| o == active);
let mut selection = dialoguer::Select::with_theme(&theme);
if let Some(active) = active {
selection.default(active);
}
let result = selection
.items(&options.iter().map(|p| p.to_string()).collect::<Vec<_>>())
.interact_opt()
.unwrap();
result.map(|i| &options[i])
}
pub fn select_with_prompt<'a, T>(prompt: &str, options: &'a [T], active: &'a T) -> Option<&'a T>
where
T: fmt::Display + Eq + PartialEq,
{
let theme = theme();
let active = options.iter().position(|o| o == active);
let mut selection = dialoguer::Select::with_theme(&theme);
selection.with_prompt(prompt);
if let Some(active) = active {
selection.default(active);
}
let result = selection
.items(&options.iter().map(|p| p.to_string()).collect::<Vec<_>>())
.interact_opt()
.unwrap();
result.map(|i| &options[i])
}
pub fn markdown(content: &str) {
if !content.is_empty() {
if command::bat(["-p", "-l", "md"], content).is_err() {
blob(content);
}
}
}
fn _info(args: std::fmt::Arguments) {
println!("{}", args);
}

View File

@ -0,0 +1,86 @@
use radicle::git;
use crate::terminal as term;
/// How a comment is to be supplied by the user for a patch or issue on the terminal.
#[derive(Clone, Debug)]
pub enum Comment {
/// Prompt user to write comment in editor.
Edit,
/// Don't leave a comment.
Blank,
/// Use the following string as comment.
Text(String),
}
impl Comment {
/// Get the comment as a string according to the method.
pub fn get(self, help: &str) -> String {
let comment = match self {
Comment::Edit => term::Editor::new()
.require_save(true)
.trim_newlines(true)
.extension(".markdown")
.edit(help)
.unwrap(),
Comment::Blank => None,
Comment::Text(c) => Some(c),
};
let comment = comment.unwrap_or_default().replace(help, "");
let comment = comment.trim();
comment.to_owned()
}
pub fn append(&mut self, arg: &str) {
if let Comment::Text(v) = self {
v.extend(["\n\n", arg]);
} else {
*self = Comment::Text(arg.into());
};
}
}
impl Default for Comment {
fn default() -> Self {
Self::Edit
}
}
/// List the given commits in a table.
pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
let mut table = term::Table::default();
for commit in commits {
let message = commit
.summary_bytes()
.unwrap_or_else(|| commit.message_bytes());
table.push([
term::format::secondary(term::format::oid(&commit.id().into())),
term::format::italic(String::from_utf8_lossy(message)),
]);
}
table.render();
Ok(())
}
/// Print commits ahead and behind.
pub fn print_commits_ahead_behind(
repo: &git::raw::Repository,
left: git::raw::Oid,
right: git::raw::Oid,
) -> anyhow::Result<()> {
let (ahead, behind) = repo.graph_ahead_behind(left, right)?;
term::info!(
"{} commit(s) ahead, {} commit(s) behind",
term::format::positive(ahead),
if behind > 0 {
term::format::negative(behind)
} else {
term::format::dim(behind)
}
);
Ok(())
}

View File

@ -0,0 +1,77 @@
use dialoguer::console::style;
use indicatif::{ProgressBar, ProgressFinish, ProgressStyle};
use crate::terminal as term;
pub struct Spinner {
progress: ProgressBar,
message: String,
}
impl Drop for Spinner {
fn drop(&mut self) {
// TODO: Set error that will be output on fail.
if !self.progress.is_finished() {
self.set_failed();
}
}
}
impl Spinner {
pub fn finish(&self) {
self.progress.finish_and_clear();
term::success!("{}", &self.message);
}
pub fn done(self) {
self.progress.finish_and_clear();
term::info!("{}", &self.message);
}
pub fn failed(mut self) {
self.set_failed();
}
pub fn error(self, err: anyhow::Error) -> anyhow::Error {
self.progress.finish_and_clear();
term::eprintln(style("!!").red().reverse(), style(&err).red());
err
}
pub fn clear(self) {
self.progress.finish_and_clear();
}
pub fn message(&mut self, msg: impl Into<String>) {
let msg = msg.into();
self.progress.set_message(msg.clone());
self.message = msg;
}
pub fn set_failed(&mut self) {
self.progress.finish_and_clear();
term::eprintln(style("!!").red().reverse(), &self.message);
}
}
pub fn spinner(message: impl ToString) -> Spinner {
let message = message.to_string();
let style = ProgressStyle::default_spinner()
.tick_strings(&[
&style("\\ ").yellow().to_string(),
&style("| ").yellow().to_string(),
&style("/ ").yellow().to_string(),
&style("| ").yellow().to_string(),
])
.template("{spinner} {msg}")
.on_finish(ProgressFinish::AndClear);
let progress = ProgressBar::new(!0);
progress.set_style(style);
progress.enable_steady_tick(99);
progress.set_message(message.clone());
Spinner { message, progress }
}

View File

@ -0,0 +1,80 @@
use std::fmt::Write;
use crate::terminal as term;
#[derive(Debug, Default)]
pub struct TableOptions {
pub overflow: bool,
}
#[derive(Debug)]
pub struct Table<const W: usize> {
rows: Vec<[String; W]>,
widths: [usize; W],
opts: TableOptions,
}
impl<const W: usize> Table<W> {
pub fn new(opts: TableOptions) -> Self {
Self {
rows: Vec::new(),
widths: [0; W],
opts,
}
}
pub fn default() -> Self {
Self {
rows: Vec::new(),
widths: [0; W],
opts: TableOptions::default(),
}
}
pub fn push(&mut self, row: [String; W]) {
for (i, cell) in row.iter().enumerate() {
self.widths[i] = self.widths[i].max(console::measure_text_width(cell));
}
self.rows.push(row);
}
pub fn render(self) {
let width = term::width(); // Terminal width.
for row in &self.rows {
let mut output = String::new();
let cells = row.len();
for (i, cell) in row.iter().enumerate() {
if i == cells - 1 || self.opts.overflow {
write!(output, "{}", cell).ok();
} else {
write!(
output,
"{} ",
console::pad_str(cell, self.widths[i], console::Alignment::Left, None)
)
.ok();
}
}
println!("{}", console::truncate_str(&output, width - 1, ""));
}
}
pub fn render_tree(self) {
for (r, row) in self.rows.iter().enumerate() {
if r != self.rows.len() - 1 {
print!("├── ");
} else {
print!("└── ");
}
for (i, cell) in row.iter().enumerate() {
print!(
"{} ",
console::pad_str(cell, self.widths[i], console::Alignment::Left, None)
);
}
println!();
}
}
}

View File

@ -0,0 +1,74 @@
use std::fmt;
use crate::terminal as term;
pub struct TextBox {
pub body: String,
first: bool,
last: bool,
}
impl TextBox {
pub fn new(body: String) -> Self {
Self {
body,
first: true,
last: true,
}
}
/// Is this text box the last one in the list?
pub fn last(mut self, connect: bool) -> Self {
self.last = connect;
self
}
/// Is this text box the first one in the list?
pub fn first(mut self, connect: bool) -> Self {
self.first = connect;
self
}
}
impl fmt::Display for TextBox {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut width = self
.body
.lines()
.map(console::measure_text_width)
.max()
.unwrap_or(0)
+ 2;
if term::width() < width + 2 {
width = term::width() - 2
}
let (connector, header_width) = if !self.first {
("", width - 1)
} else {
("", width)
};
writeln!(f, "┌{}{}┐", connector, "".repeat(header_width))?;
for l in self.body.lines() {
writeln!(
f,
"│ {}│",
console::pad_str(l, width - 1, console::Alignment::Left, Some(""))
)?;
}
let (connector, footer_width) = if !self.last {
("", width - 1)
} else {
("", width)
};
writeln!(f, "└{}{}┘", connector, "".repeat(footer_width))?;
if !self.last {
writeln!(f, "")?;
}
Ok(())
}
}

View File

@ -35,6 +35,7 @@ use crate::service::message::{NodeAnnouncement, RefsAnnouncement};
use crate::storage;
use crate::storage::{Inventory, ReadRepository, RefUpdate, WriteRepository, WriteStorage};
pub use crate::node::NodeId;
pub use crate::service::config::{Config, Network};
pub use crate::service::message::{Message, ZeroBytes};
pub use crate::service::session::Session;
@ -66,9 +67,6 @@ pub const MAX_TIME_DELTA: LocalDuration = LocalDuration::from_mins(60);
/// Maximum attempts to connect to a peer before we give up.
pub const MAX_CONNECTION_ATTEMPTS: usize = 3;
/// Network node identifier.
pub type NodeId = crypto::PublicKey;
/// A service event.
#[derive(Debug, Clone)]
pub enum Event {

View File

@ -6,6 +6,7 @@ use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use crate::crypto::PublicKey;
use crate::identity::Id;
pub use features::Features;
@ -33,6 +34,9 @@ pub trait Handle {
fn shutdown(self) -> Result<(), Error>;
}
/// Public node & device identifier.
pub type NodeId = PublicKey;
/// Node controller.
#[derive(Debug)]
pub struct Node {