cli: Simplify & improve CLI output

Improvements to the CLI output as well as converging towards
`termion` as a backend.

The crates we were using for prompting and spinners were based on
a terminal backend called `console`, which is not really used
anywhere else. There were also limitations in terms of the output
of these crates.

Therefore, we switched to the `inquire` crate for prompting, using
the `termion` backend, which is very standard.

Additionally, we simplify the terminal output to use symbols:

! for Warning
✓ for Success
✗ for Error

... Instead of a mix of words and symbols.

We also include a modified version of the `yansi` crate which plays
well with our table formatting functions by automatically truncating
and padding output in a unicode-aware way.
This commit is contained in:
Alexis Sellier 2023-02-16 22:57:46 +01:00
parent 11da344e3a
commit 0a93fa833e
No known key found for this signature in database
44 changed files with 2003 additions and 607 deletions

136
Cargo.lock generated
View File

@ -402,19 +402,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a90734b3d5dcf656e7624cca6bce9c3a90ee11f900e80141a7427ccfb3d317" checksum = "82a90734b3d5dcf656e7624cca6bce9c3a90ee11f900e80141a7427ccfb3d317"
[[package]]
name = "console"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d79fbe8970a77e3e34151cc13d3b3e248aa0faaecb9f6091fa07ebefe5ad60"
dependencies = [
"encode_unicode",
"lazy_static",
"libc",
"unicode-width",
"windows-sys",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.1" version = "0.9.1"
@ -643,18 +630,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "dialoguer"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af3c796f3b0b408d9fd581611b47fa850821fcb84aa640b83a3c1a5be2d691f2"
dependencies = [
"console",
"shell-words",
"tempfile",
"zeroize",
]
[[package]] [[package]]
name = "diff" name = "diff"
version = "0.1.13" version = "0.1.13"
@ -682,6 +657,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "dyn-clone"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9b0705efd4599c15a38151f4721f7bc388306f61084d3bfd50bd07fbca5cb60"
[[package]] [[package]]
name = "ec25519" name = "ec25519"
version = "0.1.0" version = "0.1.0"
@ -757,12 +738,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "encode_unicode"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.2.8" version = "0.2.8"
@ -1173,18 +1148,6 @@ dependencies = [
"hashbrown", "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]] [[package]]
name = "inout" name = "inout"
version = "0.1.3" version = "0.1.3"
@ -1194,6 +1157,23 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "inquire"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a94f0659efe59329832ba0452d3ec753145fc1fb12a8e1d60de4ccf99f5364"
dependencies = [
"bitflags",
"dyn-clone",
"lazy_static",
"newline-converter",
"tempfile",
"termion 1.5.6",
"thiserror",
"unicode-segmentation",
"unicode-width",
]
[[package]] [[package]]
name = "instant" name = "instant"
version = "0.1.12" version = "0.1.12"
@ -1447,6 +1427,15 @@ dependencies = [
"socket2", "socket2",
] ]
[[package]]
name = "newline-converter"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f71d09d5c87634207f894c6b31b6a2b2c64ea3bdcf71bd5599fdbbe1600c00f"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "noise-framework" name = "noise-framework"
version = "0.2.0" version = "0.2.0"
@ -1558,10 +1547,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "number_prefix" name = "numtoa"
version = "0.4.0" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
[[package]] [[package]]
name = "once_cell" name = "once_cell"
@ -1833,12 +1822,12 @@ version = "0.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"console", "concolor",
"dialoguer", "inquire",
"indicatif",
"json-color", "json-color",
"lexopt", "lexopt",
"log", "log",
"once_cell",
"pretty_assertions", "pretty_assertions",
"radicle", "radicle",
"radicle-cli-test", "radicle-cli-test",
@ -1850,8 +1839,11 @@ dependencies = [
"serde_yaml", "serde_yaml",
"similar", "similar",
"tempfile", "tempfile",
"termion 2.0.1",
"thiserror", "thiserror",
"timeago", "timeago",
"unicode-segmentation",
"unicode-width",
"ureq", "ureq",
"zeroize", "zeroize",
] ]
@ -2064,6 +2056,7 @@ dependencies = [
"anyhow", "anyhow",
"git-ref-format", "git-ref-format",
"radicle", "radicle",
"radicle-cli",
] ]
[[package]] [[package]]
@ -2146,6 +2139,15 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "redox_termios"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f"
dependencies = [
"redox_syscall",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.7.1" version = "1.7.1"
@ -2397,12 +2399,6 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.1.0" version = "1.1.0"
@ -2632,6 +2628,30 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "termion"
version = "1.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e"
dependencies = [
"libc",
"numtoa",
"redox_syscall",
"redox_termios",
]
[[package]]
name = "termion"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90"
dependencies = [
"libc",
"numtoa",
"redox_syscall",
"redox_termios",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.38" version = "1.0.38"
@ -2919,6 +2939,12 @@ dependencies = [
"tinyvec", "tinyvec",
] ]
[[package]]
name = "unicode-segmentation"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.1.10" version = "0.1.10"

View File

@ -9,7 +9,7 @@ use snapbox::{Assert, Substitutions};
use thiserror::Error; use thiserror::Error;
/// Error lines in the CLI are prefixed with this string. /// Error lines in the CLI are prefixed with this string.
const ERROR_PREFIX: &str = "=="; const ERROR_PREFIX: &str = "";
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum Error { pub enum Error {

View File

@ -13,18 +13,21 @@ path = "src/main.rs"
[dependencies] [dependencies]
anyhow = { version = "1" } anyhow = { version = "1" }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
console = { version = "0.15" } concolor = { version = "0", features = ["api_unstable"] }
dialoguer = { version = "0.10.0" } inquire = { version = "0.5.3", default-features = false, features = ["termion", "editor"] }
indicatif = { version = "0.16.2" }
json-color = { version = "0.7" } json-color = { version = "0.7" }
lexopt = { version = "0.2" } lexopt = { version = "0.2" }
log = { version = "0.4", features = ["std"] } log = { version = "0.4", features = ["std"] }
once_cell = { version = "1.13" }
serde = { version = "1.0" } serde = { version = "1.0" }
serde_json = { version = "1" } serde_json = { version = "1" }
serde_yaml = { version = "0.8" } serde_yaml = { version = "0.8" }
similar = { version = "2.2.1" } similar = { version = "2.2.1" }
termion = { version = "2" }
thiserror = { version = "1" } thiserror = { version = "1" }
timeago = { version = "0.3", default-features = false } timeago = { version = "0.3", default-features = false }
unicode-width = { version = "0.1.10", default-features = false }
unicode-segmentation = { version = "1.7.1" }
ureq = { version = "2.6.1", default-features = false, features = ["json"] } ureq = { version = "2.6.1", default-features = false, features = ["json"] }
zeroize = { version = "1.1" } zeroize = { version = "1.1" }
@ -46,6 +49,6 @@ path = "../radicle-crypto"
[dev-dependencies] [dev-dependencies]
pretty_assertions = { version = "1.3.0" } pretty_assertions = { version = "1.3.0" }
tempfile = { version = "3.3.0" }
radicle = { path = "../radicle", features = ["test"] } radicle = { path = "../radicle", features = ["test"] }
radicle-node = { path = "../radicle-node", features = ["test"] } radicle-node = { path = "../radicle-node", features = ["test"] }
tempfile = { version = "3.3.0" }

View File

@ -6,12 +6,10 @@ $ rad auth
Initializing your 🌱 profile and identity Initializing your 🌱 profile and identity
ok Creating your 🌱 Ed25519 keypair... Creating your 🌱 Ed25519 keypair...
ok Profile did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created. ✓ Your Radicle ID is did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi. This identifies your device.
Your radicle Node ID is z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi. This identifies your device. 👉 To create a radicle project, run `rad init` from a git repository.
=> To create a radicle project, run `rad init` from a git repository.
``` ```
You can get the above information at all times using the `self` command: You can get the above information at all times using the `self` command:

View File

@ -6,7 +6,7 @@ $ rad checkout rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
Initializing local checkout for 🌱 rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji (heartwood) Initializing local checkout for 🌱 rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji (heartwood)
ok Performing checkout... Performing checkout...
🌱 Project checkout successful under ./heartwood 🌱 Project checkout successful under ./heartwood

View File

@ -2,6 +2,6 @@ Trying to clone a repository that is not in our routing table returns an error:
``` ```
$ rad clone rad:zVNuptPuk5XauitpCWSNVCXGGfXW $ rad clone rad:zVNuptPuk5XauitpCWSNVCXGGfXW
ok Tracking relationship established for rad:zVNuptPuk5XauitpCWSNVCXGGfXW Tracking relationship established for rad:zVNuptPuk5XauitpCWSNVCXGGfXW
== Clone failed no seeds found for rad:zVNuptPuk5XauitpCWSNVCXGGfXW ✗ Clone failed: no seeds found for rad:zVNuptPuk5XauitpCWSNVCXGGfXW
``` ```

View File

@ -3,12 +3,12 @@ To create a local copy of a repository on the radicle network, we use the
``` ```
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji $ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
ok Tracking relationship established for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji Tracking relationship established for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
ok Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi.. Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi..
ok Forking under z6Mkt67…v4N1tRk.. Forking under z6Mkt67…v4N1tRk..
ok Creating checkout in ./heartwood.. Creating checkout in ./heartwood..
ok Remote z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created Remote z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created
ok Remote-tracking branch z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSL…StBU8Vi Remote-tracking branch z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSL…StBU8Vi
🌱 Project successfully cloned under [..]/heartwood/ 🌱 Project successfully cloned under [..]/heartwood/

View File

@ -16,7 +16,7 @@ work.
``` ```
$ rad delegate add did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji $ rad delegate add did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
Added delegate 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG' Added delegate 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG'
ok Update successful! Update successful!
``` ```
Let's convince ourselves that there's another delegate. Let's convince ourselves that there's another delegate.
@ -35,7 +35,7 @@ the torch and remove ourselves from the delegate set.
``` ```
$ rad delegate remove did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji $ rad delegate remove did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
Removed delegate 'did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi' Removed delegate 'did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
ok Update successful! Update successful!
``` ```
``` ```

View File

@ -6,10 +6,10 @@ delegates creating proposals concurrently.
``` ```
$ rad id edit --title "Add Alice" --description "Add Alice as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm $ rad id edit --title "Add Alice" --description "Add Alice as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
ok Identity proposal '57332790a2eabc0b2fd8c7ff48c3579d5812d405' created 🌱 Identity proposal '57332790a2eabc0b2fd8c7ff48c3579d5812d405' created 🌱
title: Add Alice title: Add Alice
description: Add Alice as a delegate description: Add Alice as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -43,15 +43,15 @@ keys: []
Quorum Reached Quorum Reached
no 👎 no
``` ```
``` ```
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --no-confirm $ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --no-confirm
ok Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' created 🌱 Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' created 🌱
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -85,7 +85,7 @@ keys: []
Quorum Reached Quorum Reached
no 👎 no
``` ```
Now, if the first proposal was accepted and committed before the Now, if the first proposal was accepted and committed before the
@ -94,10 +94,10 @@ through that and see what happens.
``` ```
$ rad id accept 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm $ rad id accept 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
ok Accepted proposal ✓ Accepted proposal ✓
title: Add Alice title: Add Alice
description: Add Alice as a delegate description: Add Alice as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -133,15 +133,15 @@ keys: []
Quorum Reached Quorum Reached
yes 👍 yes
``` ```
``` ```
$ rad id commit 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm $ rad id commit 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
ok Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e' 🌱 Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e' 🌱
title: Add Alice title: Add Alice
description: Add Alice as a delegate description: Add Alice as a delegate
status: committed status: committed
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -177,20 +177,20 @@ keys: []
Quorum Reached Quorum Reached
yes 👍 yes
``` ```
Now, when we go to accept the second proposal: Now, when we go to accept the second proposal:
``` ```
$ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm $ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
** Warning: Revision is out of date ! Warning: Revision is out of date
** Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f ! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
=> Consider using 'rad id rebase' to update the proposal to the latest identity 👉 Consider using 'rad id rebase' to update the proposal to the latest identity
ok Accepted proposal ✓ Accepted proposal ✓
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -226,7 +226,7 @@ keys: []
Quorum Reached Quorum Reached
yes 👍 yes
``` ```
Note that a warning was emitted: Note that a warning was emitted:
@ -239,22 +239,21 @@ If we attempt to commit this revision, the command will fail:
``` ```
$ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm $ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
** Warning: Revision is out of date ! Warning: Revision is out of date
** Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f ! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
=> Consider using 'rad id rebase' to update the proposal to the latest identity 👉 Consider using 'rad id rebase' to update the proposal to the latest identity
== Id failed ✗ Id failed: the identity hashes do match 'd96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f' for the revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1'
the identity hashes do match 'd96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f' for the revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1'
``` ```
So, let's fix this by running a rebase on the proposal's revision: So, let's fix this by running a rebase on the proposal's revision:
``` ```
$ rad id rebase c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm $ rad id rebase c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
ok Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' rebased 🌱 Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' rebased 🌱
ok Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4' Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4'
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -288,18 +287,18 @@ keys: []
Quorum Reached Quorum Reached
no 👎 no
``` ```
We can now update the proposal to have both keys in the delegates set: We can now update the proposal to have both keys in the delegates set:
``` ```
$ rad id update c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4 --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm $ rad id update c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4 --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
ok Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' updated 🌱 Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' updated 🌱
ok Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6' Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6'
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -333,7 +332,7 @@ keys: []
Quorum Reached Quorum Reached
no 👎 no
``` ```
Finally, we can accept and commit this proposal, creating the final Finally, we can accept and commit this proposal, creating the final
@ -343,10 +342,10 @@ $ rad id show c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --revisions
``` ```
$ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm $ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm
ok Accepted proposal ✓ Accepted proposal ✓
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -382,15 +381,15 @@ keys: []
Quorum Reached Quorum Reached
yes 👍 yes
``` ```
``` ```
$ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm $ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm
ok Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f' 🌱 Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f' 🌱
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: committed status: committed
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -426,5 +425,5 @@ keys: []
Quorum Reached Quorum Reached
yes 👍 yes
``` ```

View File

@ -14,10 +14,10 @@ Let's add Bob as a delegate using their DID
``` ```
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm $ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
ok Identity proposal '06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39' created 🌱 Identity proposal '06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39' created 🌱
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -51,7 +51,7 @@ keys: []
Quorum Reached Quorum Reached
no 👎 no
``` ```
Before moving on, let's take a few notes on this output. The first Before moving on, let's take a few notes on this output. The first
@ -84,16 +84,16 @@ Finally, we can see whether the `Quorum` was reached:
Quorum Reached Quorum Reached
no 👎 no
Let's see what happens when we reject the change: Let's see what happens when we reject the change:
``` ```
$ rad id reject 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm $ rad id reject 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
ok Rejected proposal ✗ ✓ Rejected proposal 👎
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -129,7 +129,7 @@ keys: [
Quorum Reached Quorum Reached
no 👎 no
``` ```
Our key was added to the `Rejected` set of `keys` and the `total` Our key was added to the `Rejected` set of `keys` and the `total`
@ -146,10 +146,10 @@ Instead, let's accept the proposal:
``` ```
$ rad id accept 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm $ rad id accept 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
ok Accepted proposal ✓ Accepted proposal ✓
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -185,7 +185,7 @@ keys: []
Quorum Reached Quorum Reached
yes 👍 yes
``` ```
Our key has changed from the `Rejected` set to the `Accepted` set Our key has changed from the `Rejected` set to the `Accepted` set
@ -202,16 +202,16 @@ As well as that, the `Quorum` has now been reached:
Quorum Reached Quorum Reached
yes 👍 yes
At this point, we can commit the proposal and update the identity: At this point, we can commit the proposal and update the identity:
``` ```
$ rad id commit 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm $ rad id commit 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
ok Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0' 🌱 Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0' 🌱
title: Add Bob title: Add Bob
description: Add Bob as a delegate description: Add Bob as a delegate
status: committed status: committed
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -247,7 +247,7 @@ keys: []
Quorum Reached Quorum Reached
yes 👍 yes
``` ```
Let's say we decide to also change the `threshold`, we can do so using Let's say we decide to also change the `threshold`, we can do so using
@ -255,10 +255,10 @@ the `--threshold` option:
``` ```
$ rad id edit --title "Update threshold" --description "Update to safer threshold" --threshold 2 --no-confirm $ rad id edit --title "Update threshold" --description "Update to safer threshold" --threshold 2 --no-confirm
ok Identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' created 🌱 Identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' created 🌱
title: Update threshold title: Update threshold
description: Update to safer threshold description: Update to safer threshold
status: open status: open
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -292,17 +292,17 @@ keys: []
Quorum Reached Quorum Reached
no 👎 no
``` ```
But we change our minds and decide to close the proposal instead: But we change our minds and decide to close the proposal instead:
``` ```
$ rad id close dc00640d3152ea5f1df59f39f2f5983d2ad21810 --no-confirm $ rad id close dc00640d3152ea5f1df59f39f2f5983d2ad21810 --no-confirm
ok Closed identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' Closed identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810'
title: Update threshold title: Update threshold
description: Update to safer threshold description: Update to safer threshold
status: closed status: closed
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -336,7 +336,7 @@ keys: []
Quorum Reached Quorum Reached
no 👎 no
``` ```
The proposal is now closed and cannot be committed. If at a later date The proposal is now closed and cannot be committed. If at a later date
@ -348,8 +348,8 @@ Radicle identity, then we can use the list command:
``` ```
$ rad id list $ rad id list
06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 "Add Bob" committed 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 "Add Bob" committed
dc00640d3152ea5f1df59f39f2f5983d2ad21810 "Update threshold" closed dc00640d3152ea5f1df59f39f2f5983d2ad21810 "Update threshold" closed
``` ```
And if we want to view the latest state of any proposal we can use the And if we want to view the latest state of any proposal we can use the
@ -359,7 +359,7 @@ show command:
$ rad id show dc00640d3152ea5f1df59f39f2f5983d2ad21810 $ rad id show dc00640d3152ea5f1df59f39f2f5983d2ad21810
title: Update threshold title: Update threshold
description: Update to safer threshold description: Update to safer threshold
status: closed status: closed
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Document Diff Document Diff
@ -393,7 +393,7 @@ keys: []
Quorum Reached Quorum Reached
no 👎 no
``` ```
On a final note, these examples used `--no-confirm`. The default mode On a final note, these examples used `--no-confirm`. The default mode

View File

@ -7,13 +7,13 @@ $ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" -
Initializing local 🌱 project in . Initializing local 🌱 project in .
ok Project heartwood created Project heartwood created
{ {
"name": "heartwood", "name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack", "description": "Radicle Heartwood Protocol & Stack",
"defaultBranch": "master" "defaultBranch": "master"
} }
ok Syncing inventory.. Syncing inventory..
Your project id is rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji. You can show it any time by running: Your project id is rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji. You can show it any time by running:
rad . rad .

View File

@ -7,7 +7,7 @@ $ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" -
Initializing local 🌱 project in . Initializing local 🌱 project in .
ok Project heartwood created Project heartwood created
{ {
"name": "heartwood", "name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack", "description": "Radicle Heartwood Protocol & Stack",

View File

@ -29,8 +29,8 @@ $ rad patch open --message "define power requirements" --no-confirm
🌱 Creating patch for heartwood 🌱 Creating patch for heartwood
ok Pushing HEAD to storage... Pushing HEAD to storage...
ok Analyzing remotes... Analyzing remotes...
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master (f2de534) <- z6MknSLStBU8Vi/flux-capacitor-power (3e674d1) z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master (f2de534) <- z6MknSLStBU8Vi/flux-capacitor-power (3e674d1)
1 commit(s) ahead, 0 commit(s) behind 1 commit(s) ahead, 0 commit(s) behind
@ -45,7 +45,7 @@ No description provided.
╰─────────────────────────────────── ╰───────────────────────────────────
ok Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 created 🌱 Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 created 🌱
``` ```
It will now be listed as one of the project's open patches. It will now be listed as one of the project's open patches.
@ -53,13 +53,13 @@ It will now be listed as one of the project's open patches.
``` ```
$ rad patch $ rad patch
- YOU PROPOSED - YOU PROPOSED
define power requirements d4ef85f57a8 R0 3e674d1 (flux-capacitor-power) ahead 1, behind 0 define power requirements d4ef85f57a8 R0 3e674d1 (flux-capacitor-power) ahead 1, behind 0
└─ * opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [..] └─ * opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [..]
└─ * patch id d4ef85f57a849bd845915d7a66a2192cd23811f6 └─ * patch id d4ef85f57a849bd845915d7a66a2192cd23811f6
- OTHERS PROPOSED - OTHERS PROPOSED
Nothing to show. Nothing to show.
@ -98,14 +98,14 @@ $ rad patch update --message "Add README, just for the fun" --no-confirm d4ef85f
🌱 Updating patch for heartwood 🌱 Updating patch for heartwood
ok Pushing HEAD to storage... Pushing HEAD to storage...
ok Analyzing remotes... Analyzing remotes...
d4ef85f57a8 R0 (3e674d1) -> R1 (27857ec) d4ef85f57a8 R0 (3e674d1) -> R1 (27857ec)
1 commit(s) ahead, 0 commit(s) behind 1 commit(s) ahead, 0 commit(s) behind
ok Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 updated 🌱 Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 updated 🌱
``` ```
@ -122,6 +122,6 @@ Now, let's checkout the patch that we just created:
``` ```
$ rad patch checkout d4ef85f57a849bd845915d7a66a2192cd23811f6 $ rad patch checkout d4ef85f57a849bd845915d7a66a2192cd23811f6
ok Performing patch checkout... Performing patch checkout...
ok Switched to branch patch/d4ef85f57a8 Switched to branch patch/d4ef85f57a8
``` ```

View File

@ -10,9 +10,9 @@ Now let's delete the `heartwood` project:
``` ```
$ rad rm rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --no-confirm $ rad rm rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --no-confirm
** Warning: Failed to untrack repository: failed to connect to node: No such file or directory (os error 2) ! Warning: Failed to untrack repository: failed to connect to node: No such file or directory (os error 2)
** Warning: Make sure to untrack this repository when your node is running ! Warning: Make sure to untrack this repository when your node is running
ok Successfully removed project rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from storage Successfully removed project rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from storage
``` ```
We can check our repositories again to see if it was deleted: We can check our repositories again to see if it was deleted:

View File

@ -80,20 +80,18 @@ pub fn init(options: Options) -> anyhow::Result<()> {
} }
let home = profile::home()?; let home = profile::home()?;
let passphrase = term::read_passphrase(options.stdin, true)?; let passphrase = if options.stdin {
term::passphrase_stdin()
} else {
term::passphrase_confirm()
}?;
let spinner = term::spinner("Creating your 🌱 Ed25519 keypair..."); let spinner = term::spinner("Creating your 🌱 Ed25519 keypair...");
let profile = Profile::init(home, passphrase)?; let profile = Profile::init(home, passphrase)?;
spinner.finish(); spinner.finish();
term::success!( term::success!(
"Profile {} created.", "Your Radicle ID is {}. This identifies your device.",
term::format::highlight(profile.did().to_string()) term::format::highlight(profile.did())
);
term::blank();
term::info!(
"Your radicle Node ID is {}. This identifies your device.",
term::format::highlight(profile.id().to_string())
); );
term::blank(); term::blank();
@ -108,6 +106,7 @@ pub fn init(options: Options) -> anyhow::Result<()> {
pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> { pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> {
let agent = ssh::agent::Agent::connect()?; let agent = ssh::agent::Agent::connect()?;
// TODO: Only show this if we're not authenticated.
term::headline(&format!( term::headline(&format!(
"🌱 Authenticating as {}", "🌱 Authenticating as {}",
term::format::Identity::new(profile).styled() term::format::Identity::new(profile).styled()
@ -119,7 +118,11 @@ pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> {
// TODO: We should show the spinner on the passphrase prompt, // TODO: We should show the spinner on the passphrase prompt,
// otherwise it seems like the passphrase is valid even if it isn't. // otherwise it seems like the passphrase is valid even if it isn't.
let passphrase = term::read_passphrase(options.stdin, false)?; let passphrase = if options.stdin {
term::passphrase_stdin()
} else {
term::passphrase()
}?;
let spinner = term::spinner("Unlocking..."); let spinner = term::spinner("Unlocking...");
let mut agent = ssh::agent::Agent::connect()?; let mut agent = ssh::agent::Agent::connect()?;
let secret = profile let secret = profile

View File

@ -97,7 +97,7 @@ pub fn execute(options: Options, profile: &Profile) -> anyhow::Result<PathBuf> {
.identity_of(&remote) .identity_of(&remote)
.context("project could not be found in local storage")?; .context("project could not be found in local storage")?;
let payload = doc.project()?; let payload = doc.project()?;
let path = PathBuf::from(payload.name().clone()); let path = PathBuf::from(payload.name());
transport::local::register(storage.clone()); transport::local::register(storage.clone());

View File

@ -71,8 +71,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let repo = storage.repository(id)?; let repo = storage.repository(id)?;
let payload = serde_json::to_string_pretty(&project.payload)?; let payload = serde_json::to_string_pretty(&project.payload)?;
match term::Editor::new().edit(&payload)? { match term::Editor::new().extension("json").edit(payload) {
Some(updated_payload) => { Ok(Some(updated_payload)) => {
project.payload = serde_json::from_str(&updated_payload)?; project.payload = serde_json::from_str(&updated_payload)?;
project.sign(&signer).and_then(|(_, sig)| { project.sign(&signer).and_then(|(_, sig)| {
project.update( project.update(
@ -83,7 +83,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
) )
})?; })?;
} }
None => return Err(anyhow!("Operation aborted!")), _ => return Err(anyhow!("Operation aborted!")),
} }
term::success!("Update successful!"); term::success!("Update successful!");

View File

@ -41,7 +41,7 @@ pub struct Metadata {
impl Metadata { impl Metadata {
fn edit(self) -> anyhow::Result<Self> { fn edit(self) -> anyhow::Result<Self> {
let yaml = serde_yaml::to_string(&self)?; let yaml = serde_yaml::to_string(&self)?;
match term::Editor::new().edit(&yaml)? { match term::Editor::new().edit(yaml)? {
Some(meta) => Ok(serde_yaml::from_str(&meta).context("failed to parse proposal meta")?), Some(meta) => Ok(serde_yaml::from_str(&meta).context("failed to parse proposal meta")?),
None => return Err(anyhow!("Operation aborted!")), None => return Err(anyhow!("Operation aborted!")),
} }
@ -261,7 +261,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let yes = confirm(interactive, "Are you sure you want to reject?"); let yes = confirm(interactive, "Are you sure you want to reject?");
if yes { if yes {
proposal.reject(rid, &signer)?; proposal.reject(rid, &signer)?;
term::success!("Rejected proposal "); term::success!("Rejected proposal 👎");
print(&proposal, &previous, None)?; print(&proposal, &previous, None)?;
} }
} }
@ -578,9 +578,9 @@ fn print_revision(revision: &identity::Revision, previous: &Identity<Oid>) -> an
print!( print!(
"{}", "{}",
if revision.is_quorum_reached(previous) { if revision.is_quorum_reached(previous) {
term::format::positive(" yes") term::format::positive("👍 yes")
} else { } else {
term::format::negative(" no") term::format::negative("👎 no")
} }
); );
term::blank(); term::blank();

View File

@ -162,9 +162,9 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
term::headline(&format!( term::headline(&format!(
"Initializing local 🌱 project in {}", "Initializing local 🌱 project in {}",
if path == cwd { if path == cwd {
term::format::highlight(".") term::format::highlight(".").to_string()
} else { } else {
term::format::highlight(path.display()) term::format::highlight(path.display()).to_string()
} }
)); ));
@ -184,14 +184,14 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
let name = options.name.unwrap_or_else(|| { let name = options.name.unwrap_or_else(|| {
let default = path.file_name().map(|f| f.to_string_lossy().to_string()); let default = path.file_name().map(|f| f.to_string_lossy().to_string());
term::text_input("Name", default).unwrap() term::input("Name", default).unwrap()
}); });
let description = options let description = options
.description .description
.unwrap_or_else(|| term::text_input("Description", None).unwrap()); .unwrap_or_else(|| term::input("Description", None).unwrap());
let branch = options.branch.unwrap_or_else(|| { let branch = options.branch.unwrap_or_else(|| {
if interactive.yes() { if interactive.yes() {
term::text_input("Default branch", Some(head)).unwrap() term::input("Default branch", Some(head)).unwrap()
} else { } else {
head head
} }

View File

@ -218,8 +218,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
} }
Operation::React { id, reaction } => { Operation::React { id, reaction } => {
if let Ok(mut issue) = issues.get_mut(&id) { if let Ok(mut issue) = issues.get_mut(&id) {
let comment_id = term::comment_select(&issue).unwrap(); let (comment_id, _) = term::comment_select(&issue).unwrap();
issue.react(comment_id, reaction, &signer)?; issue.react(*comment_id, reaction, &signer)?;
} }
} }
Operation::Open { title, description } => { Operation::Open { title, description } => {
@ -235,7 +235,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
description.unwrap_or("Enter a description...".to_owned()) description.unwrap_or("Enter a description...".to_owned())
); );
if let Some(text) = term::Editor::new().edit(&doc)? { if let Ok(Some(text)) = term::Editor::new().edit(&doc) {
let mut meta = String::new(); let mut meta = String::new();
let mut frontmatter = false; let mut frontmatter = false;
let mut lines = text.lines(); let mut lines = text.lines();

View File

@ -53,8 +53,8 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let head = term::format::oid(head); let head = term::format::oid(head);
table.push([ table.push([
term::format::bold(proj.name()), term::format::bold(proj.name()),
term::format::tertiary(id.urn()), term::format::tertiary(id.urn().as_str()),
term::format::secondary(head), term::format::secondary(head.as_str()),
term::format::italic(proj.description()), term::format::italic(proj.description()),
]); ]);
}); });

View File

@ -222,8 +222,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
.dim() .dim()
.italic() .italic()
.to_string(), .to_string(),
MergeStyle::Commit => term::format::style(merge_style.to_string()) MergeStyle::Commit => term::format::yellow(merge_style.to_string())
.yellow()
.italic() .italic()
.to_string(), .to_string(),
}; };
@ -312,19 +311,13 @@ fn merge_commit(
writeln!(&mut merge_msg, "{}", MERGE_HELP_MSG.trim())?; writeln!(&mut merge_msg, "{}", MERGE_HELP_MSG.trim())?;
// Offer user the chance to edit the message before committing. // Offer user the chance to edit the message before committing.
let merge_msg = match term::Editor::new() let merge_msg = match term::Editor::new().extension("git-commit").edit(merge_msg) {
.require_save(true) Ok(Some(s)) => s
.trim_newlines(true)
.extension(".git-commit")
.edit(&merge_msg)
.unwrap()
{
Some(s) => s
.lines() .lines()
.filter(|l| !l.starts_with('#')) .filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"), .join("\n"),
None => anyhow::bail!("user aborted merge"), _ => anyhow::bail!("user aborted merge"),
}; };
// Empty message aborts merge. // Empty message aborts merge.

View File

@ -127,7 +127,7 @@ pub fn pretty_sync_status(
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let (a, b) = repo.graph_ahead_behind(revision_oid, head_oid)?; let (a, b) = repo.graph_ahead_behind(revision_oid, head_oid)?;
if a == 0 && b == 0 { if a == 0 && b == 0 {
return Ok(term::format::dim("up to date")); return Ok(term::format::dim("up to date").to_string());
} }
let ahead = term::format::positive(a); let ahead = term::format::positive(a);
@ -143,7 +143,7 @@ pub fn pretty_commit_version(
revision_oid: &Oid, revision_oid: &Oid,
repo: &Option<git::raw::Repository>, repo: &Option<git::raw::Repository>,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let mut oid = term::format::secondary(term::format::oid(*revision_oid)); let mut oid = term::format::secondary(term::format::oid(*revision_oid)).to_string();
let mut branches: Vec<String> = vec![]; let mut branches: Vec<String> = vec![];
if let Some(repo) = repo { if let Some(repo) = repo {
@ -216,6 +216,8 @@ pub fn push_to_storage(
if options.verbose { if options.verbose {
spinner.finish(); spinner.finish();
term::blob(output); term::blob(output);
return Ok(());
} }
} }
spinner.finish(); spinner.finish();

View File

@ -5,6 +5,7 @@ use radicle::git;
use radicle::prelude::*; use radicle::prelude::*;
use radicle::profile::Profile; use radicle::profile::Profile;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
use unicode_width::UnicodeWidthStr;
use crate::terminal as term; use crate::terminal as term;
@ -39,10 +40,7 @@ pub fn run(
} }
} }
term::blank(); term::blank();
term::print(format!( term::print(term::format::badge_secondary("YOU PROPOSED"));
"-{}-",
term::format::badge_secondary("YOU PROPOSED")
));
if own.is_empty() { if own.is_empty() {
term::blank(); term::blank();
@ -55,10 +53,7 @@ pub fn run(
} }
} }
term::blank(); term::blank();
term::print(format!( term::print(term::format::badge_secondary("OTHERS PROPOSED"));
"-{}-",
term::format::badge_secondary("OTHERS PROPOSED")
));
if other.is_empty() { if other.is_empty() {
term::blank(); term::blank();
@ -94,11 +89,9 @@ fn print(
)]; )];
if you { if you {
author_info.push(term::format::secondary("(you)")); author_info.push(term::format::secondary("(you)").to_string());
} }
author_info.push(term::format::dim(term::format::timestamp( author_info.push(term::format::dim(term::format::timestamp(&patch.timestamp())).to_string());
&patch.timestamp(),
)));
let (_, revision) = patch let (_, revision) = patch
.latest() .latest()
@ -120,17 +113,17 @@ fn print(
let mut badges = Vec::new(); let mut badges = Vec::new();
if peer.delegate { if peer.delegate {
badges.push(term::format::secondary("(delegate)")); badges.push(term::format::secondary("(delegate)").to_string());
} }
if peer.id == *whoami { if peer.id == *whoami {
badges.push(term::format::secondary("(you)")); badges.push(term::format::secondary("(you)").to_string());
} }
timeline.push(( timeline.push((
merge.timestamp, merge.timestamp,
format!( format!(
"{}{} by {} {}", "{}{} by {} {}",
" ".repeat(term::text_width(prefix)), " ".repeat(prefix.width()),
term::format::secondary(term::format::dim("✓ merged")), term::format::secondary(term::format::dim("✓ merged")),
term::format::tertiary(peer.id), term::format::tertiary(peer.id),
badges.join(" "), badges.join(" "),
@ -147,17 +140,17 @@ fn print(
let mut badges = Vec::new(); let mut badges = Vec::new();
if peer.delegate { if peer.delegate {
badges.push(term::format::secondary("(delegate)")); badges.push(term::format::secondary("(delegate)").to_string());
} }
if peer.id == *whoami { if peer.id == *whoami {
badges.push(term::format::secondary("(you)")); badges.push(term::format::secondary("(you)").to_string());
} }
timeline.push(( timeline.push((
review.timestamp(), review.timestamp(),
format!( format!(
"{}{} by {} {}", "{}{} by {} {}",
" ".repeat(term::text_width(prefix)), " ".repeat(prefix.width()),
verdict, verdict,
term::format::tertiary(reviewer), term::format::tertiary(reviewer),
badges.join(" "), badges.join(" "),

View File

@ -78,33 +78,45 @@ fn all(profile: &Profile) -> anyhow::Result<()> {
let mut table = term::Table::default(); let mut table = term::Table::default();
let did = profile.did(); let did = profile.did();
table.push(["ID", &term::format::tertiary(did)]); table.push([
term::format::style("ID").to_string(),
term::format::tertiary(did).to_string(),
]);
let node_id = profile.id(); let node_id = profile.id();
table.push(["Node ID", &term::format::tertiary(node_id)]); table.push([
term::format::style("Node ID").to_string(),
term::format::tertiary(node_id).to_string(),
]);
let ssh_short = ssh::fmt::fingerprint(node_id); let ssh_short = ssh::fmt::fingerprint(node_id);
table.push(["Key (hash)", &term::format::tertiary(ssh_short)]); table.push([
term::format::style("Key (hash)").to_string(),
term::format::tertiary(ssh_short).to_string(),
]);
let ssh_long = ssh::fmt::key(node_id); let ssh_long = ssh::fmt::key(node_id);
table.push(["Key (full)", &term::format::tertiary(ssh_long)]); table.push([
term::format::style("Key (full)").to_string(),
term::format::tertiary(ssh_long).to_string(),
]);
let storage_path = profile.home.storage(); let storage_path = profile.home.storage();
table.push([ table.push([
"Storage (git)", term::format::style("Storage (git)").to_string(),
&term::format::tertiary(storage_path.display()), term::format::tertiary(storage_path.display()).to_string(),
]); ]);
let keys_path = profile.home.keys(); let keys_path = profile.home.keys();
table.push([ table.push([
"Storage (keys)", term::format::style("Storage (keys)").to_string(),
&term::format::tertiary(keys_path.display()), term::format::tertiary(keys_path.display()).to_string(),
]); ]);
let node_path = profile.home.node(); let node_path = profile.home.node();
table.push([ table.push([
"Node (socket)", term::format::style("Node (socket)").to_string(),
&term::format::tertiary(node_path.join("radicle.sock").display()), term::format::tertiary(node_path.join("radicle.sock").display()).to_string(),
]); ]);
table.render(); table.render();

View File

@ -1,6 +1,9 @@
pub mod ansi;
pub mod args; pub mod args;
pub mod cell;
pub mod cob; pub mod cob;
pub mod command; pub mod command;
pub mod editor;
pub mod format; pub mod format;
pub mod io; pub mod io;
pub mod patch; pub mod patch;
@ -11,12 +14,12 @@ pub mod textbox;
use std::ffi::OsString; use std::ffi::OsString;
use std::process; use std::process;
use dialoguer::console::style;
use radicle::profile::Profile; use radicle::profile::Profile;
pub use ansi::{paint, Paint};
pub use args::{Args, Error, Help}; pub use args::{Args, Error, Help};
pub use console::measure_text_width as text_width; pub use editor::Editor;
pub use dialoguer::Editor; pub use inquire::ui::Styled;
pub use io::*; pub use io::*;
pub use spinner::{spinner, Spinner}; pub use spinner::{spinner, Spinner};
pub use table::Table; pub use table::Table;
@ -97,14 +100,14 @@ where
}; };
eprintln!( eprintln!(
"{} {} {} {}", "{} {} {} {}",
style("==").red(), Paint::red("=="),
style("Error:").red(), Paint::red("Error:"),
style(format!("rad-{}:", help.name)).red(), Paint::red(format!("rad-{}:", help.name)),
style(&err).red() Paint::red(err.to_string()),
); );
if let Some(Error::WithHint { hint, .. }) = err.downcast_ref::<Error>() { if let Some(Error::WithHint { hint, .. }) = err.downcast_ref::<Error>() {
eprintln!("{}", style(hint).yellow()); eprintln!("{}", Paint::yellow(hint));
} }
process::exit(1); process::exit(1);
@ -173,3 +176,7 @@ impl From<bool> for Interactive {
} }
} }
} }
pub fn style<T>(item: T) -> Paint<T> {
paint(item)
}

View File

@ -0,0 +1,16 @@
//! A dead simple ANSI terminal color painting library.
//!
//! This library is a port of the `yansi` crate.
//! Copyright (c) 2017 Sergio Benitez
//!
mod color;
mod paint;
mod style;
#[cfg(test)]
mod tests;
mod windows;
pub use color::Color;
pub use paint::paint;
pub use paint::Paint;
pub use style::Style;

View File

@ -0,0 +1,63 @@
use std::fmt;
use super::{Paint, Style};
/// An enum representing an ANSI color code.
#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
pub enum Color {
/// No color has been set. Nothing is changed when applied.
#[default]
Unset,
/// Black #0 (foreground code `30`, background code `40`).
Black,
/// Red: #1 (foreground code `31`, background code `41`).
Red,
/// Green: #2 (foreground code `32`, background code `42`).
Green,
/// Yellow: #3 (foreground code `33`, background code `43`).
Yellow,
/// Blue: #4 (foreground code `34`, background code `44`).
Blue,
/// Magenta: #5 (foreground code `35`, background code `45`).
Magenta,
/// Cyan: #6 (foreground code `36`, background code `46`).
Cyan,
/// White: #7 (foreground code `37`, background code `47`).
White,
/// A color number from 0 to 255, for use in 256-color terminals.
Fixed(u8),
/// A 24-bit RGB color, as specified by ISO-8613-3.
RGB(u8, u8, u8),
}
impl Color {
/// Constructs a new `Paint` structure that encapsulates `item` with the
/// foreground color set to the color `self`.
#[inline]
pub fn paint<T>(self, item: T) -> Paint<T> {
Paint::new(item).fg(self)
}
/// Constructs a new `Style` structure with the foreground color set to the
/// color `self`.
#[inline]
pub const fn style(self) -> Style {
Style::new(self)
}
pub(crate) fn ansi_fmt(&self, f: &mut dyn fmt::Write) -> fmt::Result {
match *self {
Color::Unset => Ok(()),
Color::Black => write!(f, "0"),
Color::Red => write!(f, "1"),
Color::Green => write!(f, "2"),
Color::Yellow => write!(f, "3"),
Color::Blue => write!(f, "4"),
Color::Magenta => write!(f, "5"),
Color::Cyan => write!(f, "6"),
Color::White => write!(f, "7"),
Color::Fixed(num) => write!(f, "8;5;{num}"),
Color::RGB(r, g, b) => write!(f, "8;2;{r};{g};{b}"),
}
}
}

View File

@ -0,0 +1,252 @@
use std::fmt;
use unicode_width::UnicodeWidthStr;
use super::color::Color;
use super::style::{Property, Style};
/// A structure encapsulating an item and styling.
#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
pub struct Paint<T> {
pub item: T,
pub style: Style,
}
impl Paint<&str> {
/// Return plain content.
pub fn content(&self) -> &str {
self.item
}
}
impl Paint<String> {
/// Return plain content.
pub fn content(&self) -> &str {
self.item.as_str()
}
}
impl<T> From<T> for Paint<T> {
fn from(value: T) -> Self {
Self::new(value)
}
}
impl From<&str> for Paint<String> {
fn from(item: &str) -> Self {
Self::new(item.to_string())
}
}
impl<T> Paint<T> {
/// Constructs a new `Paint` structure encapsulating `item` with no set
/// styling.
#[inline]
pub const fn new(item: T) -> Paint<T> {
Paint {
item,
style: Style {
foreground: Color::Unset,
background: Color::Unset,
properties: Property::new(),
wrap: false,
},
}
}
/// Constructs a new _wrapping_ `Paint` structure encapsulating `item` with
/// default styling.
///
/// A wrapping `Paint` converts all color resets written out by the internal
/// value to the styling of itself. This allows for seamless color wrapping
/// of other colored text.
///
/// # Performance
///
/// In order to wrap an internal value, the internal value must first be
/// written out to a local buffer and examined. As a result, displaying a
/// wrapped value is likely to result in a heap allocation and copy.
#[inline]
pub const fn wrapping(item: T) -> Paint<T> {
Paint::new(item).wrap()
}
/// Constructs a new `Paint` structure encapsulating `item` with the
/// foreground color set to the RGB color `r`, `g`, `b`.
#[inline]
pub const fn rgb(r: u8, g: u8, b: u8, item: T) -> Paint<T> {
Paint::new(item).fg(Color::RGB(r, g, b))
}
/// Constructs a new `Paint` structure encapsulating `item` with the
/// foreground color set to the fixed 8-bit color `color`.
#[inline]
pub const fn fixed(color: u8, item: T) -> Paint<T> {
Paint::new(item).fg(Color::Fixed(color))
}
pub const fn red(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Red)
}
pub const fn black(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Black)
}
pub const fn yellow(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Yellow)
}
pub const fn green(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Green)
}
pub const fn cyan(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Cyan)
}
pub const fn blue(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Blue)
}
pub const fn magenta(item: T) -> Paint<T> {
Paint::new(item).fg(Color::Magenta)
}
pub const fn white(item: T) -> Paint<T> {
Paint::new(item).fg(Color::White)
}
/// Retrieves the style currently set on `self`.
#[inline]
pub const fn style(&self) -> Style {
self.style
}
/// Retrieves a borrow to the inner item.
#[inline]
pub const fn inner(&self) -> &T {
&self.item
}
/// Sets the style of `self` to `style`.
#[inline]
pub fn with_style(mut self, style: Style) -> Paint<T> {
self.style = style;
self
}
/// Makes `self` a _wrapping_ `Paint`.
///
/// A wrapping `Paint` converts all color resets written out by the internal
/// value to the styling of itself. This allows for seamless color wrapping
/// of other colored text.
///
/// # Performance
///
/// In order to wrap an internal value, the internal value must first be
/// written out to a local buffer and examined. As a result, displaying a
/// wrapped value is likely to result in a heap allocation and copy.
#[inline]
pub const fn wrap(mut self) -> Paint<T> {
self.style.wrap = true;
self
}
/// Sets the foreground to `color`.
#[inline]
pub const fn fg(mut self, color: Color) -> Paint<T> {
self.style.foreground = color;
self
}
/// Sets the background to `color`.
#[inline]
pub const fn bg(mut self, color: Color) -> Paint<T> {
self.style.background = color;
self
}
pub fn bold(mut self) -> Self {
self.style.properties.set(Property::BOLD);
self
}
pub fn dim(mut self) -> Self {
self.style.properties.set(Property::DIM);
self
}
pub fn italic(mut self) -> Self {
self.style.properties.set(Property::ITALIC);
self
}
pub fn underline(mut self) -> Self {
self.style.properties.set(Property::UNDERLINE);
self
}
pub fn invert(mut self) -> Self {
self.style.properties.set(Property::INVERT);
self
}
pub fn strikethrough(mut self) -> Self {
self.style.properties.set(Property::STRIKETHROUGH);
self
}
pub fn blink(mut self) -> Self {
self.style.properties.set(Property::BLINK);
self
}
pub fn hidden(mut self) -> Self {
self.style.properties.set(Property::HIDDEN);
self
}
}
impl<T: UnicodeWidthStr> UnicodeWidthStr for Paint<T> {
fn width(&self) -> usize {
self.item.width()
}
fn width_cjk(&self) -> usize {
self.item.width_cjk()
}
}
impl<T: fmt::Display> fmt::Display for Paint<T> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if Paint::is_enabled() && self.style.wrap {
let mut prefix = String::new();
prefix.push_str("\x1B[0m");
self.style.fmt_prefix(&mut prefix)?;
self.style.fmt_prefix(f)?;
let item = format!("{}", self.item).replace("\x1B[0m", &prefix);
fmt::Display::fmt(&item, f)?;
self.style.fmt_suffix(f)
} else if Paint::is_enabled() {
self.style.fmt_prefix(f)?;
fmt::Display::fmt(&self.item, f)?;
self.style.fmt_suffix(f)
} else {
fmt::Display::fmt(&self.item, f)
}
}
}
impl Paint<()> {
/// Returns `true` if coloring is enabled and `false` otherwise.
pub fn is_enabled() -> bool {
concolor::get(concolor::Stream::Stdout).ansi_color()
}
}
/// Shorthand for [`Paint::new`].
pub fn paint<T>(item: T) -> Paint<T> {
Paint::new(item)
}

View File

@ -0,0 +1,267 @@
use std::fmt::{self, Display};
use std::hash::{Hash, Hasher};
use std::ops::BitOr;
use super::{Color, Paint};
#[derive(Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
pub struct Property(u8);
impl Property {
pub const BOLD: Self = Property(1 << 0);
pub const DIM: Self = Property(1 << 1);
pub const ITALIC: Self = Property(1 << 2);
pub const UNDERLINE: Self = Property(1 << 3);
pub const BLINK: Self = Property(1 << 4);
pub const INVERT: Self = Property(1 << 5);
pub const HIDDEN: Self = Property(1 << 6);
pub const STRIKETHROUGH: Self = Property(1 << 7);
pub const fn new() -> Self {
Property(0)
}
#[inline(always)]
pub const fn contains(self, other: Property) -> bool {
(other.0 & self.0) == other.0
}
#[inline(always)]
pub fn set(&mut self, other: Property) {
self.0 |= other.0;
}
#[inline(always)]
pub fn iter(self) -> Iter {
Iter {
index: 0,
properties: self,
}
}
}
impl BitOr for Property {
type Output = Self;
#[inline(always)]
fn bitor(self, rhs: Self) -> Self {
Property(self.0 | rhs.0)
}
}
pub struct Iter {
index: u8,
properties: Property,
}
impl Iterator for Iter {
type Item = usize;
fn next(&mut self) -> Option<Self::Item> {
while self.index < 8 {
let index = self.index;
self.index += 1;
if self.properties.contains(Property(1 << index)) {
return Some(index as usize);
}
}
None
}
}
/// Represents a set of styling options.
#[repr(packed)]
#[derive(Default, Debug, Eq, Ord, PartialOrd, Copy, Clone)]
pub struct Style {
pub(crate) foreground: Color,
pub(crate) background: Color,
pub(crate) properties: Property,
pub(crate) wrap: bool,
}
impl PartialEq for Style {
fn eq(&self, other: &Style) -> bool {
self.foreground == other.foreground
&& self.background == other.background
&& self.properties == other.properties
}
}
impl Hash for Style {
fn hash<H: Hasher>(&self, state: &mut H) {
self.foreground.hash(state);
self.background.hash(state);
self.properties.hash(state);
}
}
#[inline]
fn write_spliced<T: Display>(c: &mut bool, f: &mut dyn fmt::Write, t: T) -> fmt::Result {
if *c {
write!(f, ";{t}")
} else {
*c = true;
write!(f, "{t}")
}
}
impl Style {
/// Default style with the foreground set to `color` and no other set
/// properties.
#[inline]
pub const fn new(color: Color) -> Style {
// Avoiding `Default::default` since unavailable as `const`
Self {
foreground: color,
background: Color::Unset,
properties: Property::new(),
wrap: false,
}
}
/// Sets the foreground to `color`.
#[inline]
pub const fn fg(mut self, color: Color) -> Style {
self.foreground = color;
self
}
/// Sets the background to `color`.
#[inline]
pub const fn bg(mut self, color: Color) -> Style {
self.background = color;
self
}
/// Sets `self` to be wrapping.
///
/// A wrapping `Style` converts all color resets written out by the internal
/// value to the styling of itself. This allows for seamless color wrapping
/// of other colored text.
///
/// # Performance
///
/// In order to wrap an internal value, the internal value must first be
/// written out to a local buffer and examined. As a result, displaying a
/// wrapped value is likely to result in a heap allocation and copy.
#[inline]
pub const fn wrap(mut self) -> Style {
self.wrap = true;
self
}
pub fn bold(mut self) -> Self {
self.properties.set(Property::BOLD);
self
}
pub fn dim(mut self) -> Self {
self.properties.set(Property::DIM);
self
}
pub fn italic(mut self) -> Self {
self.properties.set(Property::ITALIC);
self
}
pub fn underline(mut self) -> Self {
self.properties.set(Property::UNDERLINE);
self
}
pub fn invert(mut self) -> Self {
self.properties.set(Property::INVERT);
self
}
pub fn strikethrough(mut self) -> Self {
self.properties.set(Property::STRIKETHROUGH);
self
}
/// Constructs a new `Paint` structure that encapsulates `item` with the
/// style set to `self`.
#[inline]
pub fn paint<T>(self, item: T) -> Paint<T> {
Paint::new(item).with_style(self)
}
/// Returns the foreground color of `self`.
#[inline]
pub const fn fg_color(&self) -> Color {
self.foreground
}
/// Returns the foreground color of `self`.
#[inline]
pub const fn bg_color(&self) -> Color {
self.background
}
/// Returns `true` if `self` is wrapping.
#[inline]
pub const fn is_wrapping(&self) -> bool {
self.wrap
}
#[inline(always)]
fn is_plain(&self) -> bool {
self == &Style::default()
}
/// Writes the ANSI code prefix for the currently set styles.
///
/// This method is intended to be used inside of [`fmt::Display`] and
/// [`fmt::Debug`] implementations for custom or specialized use-cases. Most
/// users should use [`Paint`] for all painting needs.
///
/// This method writes the ANSI code prefix irrespective of whether painting
/// is currently enabled or disabled. To write the prefix only if painting
/// is enabled, condition a call to this method on [`Paint::is_enabled()`].
pub fn fmt_prefix(&self, f: &mut dyn fmt::Write) -> fmt::Result {
// A user may just want a code-free string when no styles are applied.
if self.is_plain() {
return Ok(());
}
let mut splice = false;
write!(f, "\x1B[")?;
for i in self.properties.iter() {
let k = if i >= 5 { i + 2 } else { i + 1 };
write_spliced(&mut splice, f, k)?;
}
if self.background != Color::Unset {
write_spliced(&mut splice, f, "4")?;
self.background.ansi_fmt(f)?;
}
if self.foreground != Color::Unset {
write_spliced(&mut splice, f, "3")?;
self.foreground.ansi_fmt(f)?;
}
// All the codes end with an `m`.
write!(f, "m")
}
/// Writes the ANSI code suffix for the currently set styles.
///
/// This method is intended to be used inside of [`fmt::Display`] and
/// [`fmt::Debug`] implementations for custom or specialized use-cases. Most
/// users should use [`Paint`] for all painting needs.
///
/// This method writes the ANSI code suffix irrespective of whether painting
/// is currently enabled or disabled. To write the suffix only if painting
/// is enabled, condition a call to this method on [`Paint::is_enabled()`].
pub fn fmt_suffix(&self, f: &mut dyn fmt::Write) -> fmt::Result {
if self.is_plain() {
return Ok(());
}
write!(f, "\x1B[0m")
}
}

View File

@ -0,0 +1,279 @@
use std::sync::Mutex;
use super::Color::*;
use super::Paint;
/// Ensures tests are running serially.
static SERIAL: Mutex<()> = Mutex::new(());
#[test]
fn colors_enabled() {
let _guard = SERIAL.lock();
concolor::set(concolor::ColorChoice::Always);
assert_eq!(
Paint::new("text/plain").to_string(),
"text/plain".to_string()
);
assert_eq!(
Paint::red("hi").to_string(),
"\x1B[31mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::black("hi").to_string(),
"\x1B[30mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::yellow("hi").bold().to_string(),
"\x1B[1;33mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").fg(Yellow).bold().to_string(),
"\x1B[1;33mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::blue("hi").underline().to_string(),
"\x1B[4;34mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::green("hi").bold().underline().to_string(),
"\x1B[1;4;32mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::green("hi").underline().bold().to_string(),
"\x1B[1;4;32mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::magenta("hi").bg(White).to_string(),
"\x1B[47;35mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::red("hi").bg(Blue).fg(Yellow).to_string(),
"\x1B[44;33mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(),
"\x1B[44;33mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::cyan("hi").bold().bg(White).to_string(),
"\x1B[1;47;36mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::cyan("hi").underline().bg(White).to_string(),
"\x1B[4;47;36mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::cyan("hi").bold().underline().bg(White).to_string(),
"\x1B[1;4;47;36mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::cyan("hi").underline().bold().bg(White).to_string(),
"\x1B[1;4;47;36mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::fixed(100, "hi").to_string(),
"\x1B[38;5;100mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::fixed(100, "hi").bg(Magenta).to_string(),
"\x1B[45;38;5;100mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::fixed(100, "hi").bg(Fixed(200)).to_string(),
"\x1B[48;5;200;38;5;100mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::rgb(70, 130, 180, "hi").to_string(),
"\x1B[38;2;70;130;180mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(),
"\x1B[44;38;2;70;130;180mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(),
"\x1B[48;2;70;130;180;34mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::rgb(70, 130, 180, "hi")
.bg(RGB(5, 10, 15))
.to_string(),
"\x1B[48;2;5;10;15;38;2;70;130;180mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").bold().to_string(),
"\x1B[1mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").underline().to_string(),
"\x1B[4mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").bold().underline().to_string(),
"\x1B[1;4mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").dim().to_string(),
"\x1B[2mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").italic().to_string(),
"\x1B[3mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").blink().to_string(),
"\x1B[5mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").invert().to_string(),
"\x1B[7mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").hidden().to_string(),
"\x1B[8mhi\x1B[0m".to_string()
);
assert_eq!(
Paint::new("hi").strikethrough().to_string(),
"\x1B[9mhi\x1B[0m".to_string()
);
}
#[test]
fn colors_disabled() {
let _guard = SERIAL.lock();
concolor::set(concolor::ColorChoice::Never);
assert_eq!(
Paint::new("text/plain").to_string(),
"text/plain".to_string()
);
assert_eq!(Paint::red("hi").to_string(), "hi".to_string());
assert_eq!(Paint::black("hi").to_string(), "hi".to_string());
assert_eq!(Paint::yellow("hi").bold().to_string(), "hi".to_string());
assert_eq!(
Paint::new("hi").fg(Yellow).bold().to_string(),
"hi".to_string()
);
assert_eq!(Paint::blue("hi").underline().to_string(), "hi".to_string());
assert_eq!(
Paint::green("hi").bold().underline().to_string(),
"hi".to_string()
);
assert_eq!(
Paint::green("hi").underline().bold().to_string(),
"hi".to_string()
);
assert_eq!(Paint::magenta("hi").bg(White).to_string(), "hi".to_string());
assert_eq!(
Paint::red("hi").bg(Blue).fg(Yellow).to_string(),
"hi".to_string()
);
assert_eq!(
Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(),
"hi".to_string()
);
assert_eq!(
Paint::cyan("hi").bold().bg(White).to_string(),
"hi".to_string()
);
assert_eq!(
Paint::cyan("hi").underline().bg(White).to_string(),
"hi".to_string()
);
assert_eq!(
Paint::cyan("hi").bold().underline().bg(White).to_string(),
"hi".to_string()
);
assert_eq!(
Paint::cyan("hi").underline().bold().bg(White).to_string(),
"hi".to_string()
);
assert_eq!(Paint::fixed(100, "hi").to_string(), "hi".to_string());
assert_eq!(
Paint::fixed(100, "hi").bg(Magenta).to_string(),
"hi".to_string()
);
assert_eq!(
Paint::fixed(100, "hi").bg(Fixed(200)).to_string(),
"hi".to_string()
);
assert_eq!(Paint::rgb(70, 130, 180, "hi").to_string(), "hi".to_string());
assert_eq!(
Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(),
"hi".to_string()
);
assert_eq!(
Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(),
"hi".to_string()
);
assert_eq!(
Paint::blue("hi").bg(RGB(70, 130, 180)).wrap().to_string(),
"hi".to_string()
);
assert_eq!(
Paint::rgb(70, 130, 180, "hi")
.bg(RGB(5, 10, 15))
.to_string(),
"hi".to_string()
);
assert_eq!(Paint::new("hi").bold().to_string(), "hi".to_string());
assert_eq!(Paint::new("hi").underline().to_string(), "hi".to_string());
assert_eq!(
Paint::new("hi").bold().underline().to_string(),
"hi".to_string()
);
assert_eq!(Paint::new("hi").dim().to_string(), "hi".to_string());
assert_eq!(Paint::new("hi").italic().to_string(), "hi".to_string());
assert_eq!(Paint::new("hi").blink().to_string(), "hi".to_string());
assert_eq!(Paint::new("hi").invert().to_string(), "hi".to_string());
assert_eq!(Paint::new("hi").hidden().to_string(), "hi".to_string());
assert_eq!(
Paint::new("hi").strikethrough().to_string(),
"hi".to_string()
);
assert_eq!(
Paint::new("hi").strikethrough().wrap().to_string(),
"hi".to_string()
);
}
#[test]
fn wrapping() {
let _guard = SERIAL.lock();
let inner = || format!("{} b {}", Paint::red("a"), Paint::green("c"));
let inner2 = || format!("0 {} 1", Paint::magenta(&inner()).wrap());
concolor::set(concolor::ColorChoice::Always);
assert_eq!(
Paint::new("text/plain").wrap().to_string(),
"text/plain".to_string()
);
assert_eq!(Paint::new(&inner()).wrap().to_string(), inner());
assert_eq!(
Paint::new(&inner()).wrap().to_string(),
"\u{1b}[31ma\u{1b}[0m b \u{1b}[32mc\u{1b}[0m".to_string()
);
assert_eq!(
Paint::new(&inner()).fg(Blue).wrap().to_string(),
"\u{1b}[34m\u{1b}[31ma\u{1b}[0m\u{1b}[34m b \
\u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[0m"
.to_string()
);
assert_eq!(Paint::new(&inner2()).wrap().to_string(), inner2());
assert_eq!(
Paint::new(&inner2()).wrap().to_string(),
"0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[35m b \
\u{1b}[32mc\u{1b}[0m\u{1b}[35m\u{1b}[0m 1"
.to_string()
);
assert_eq!(
Paint::new(&inner2()).fg(Blue).wrap().to_string(),
"\u{1b}[34m0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[34m\u{1b}[35m b \
\u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[35m\u{1b}[0m\u{1b}[34m 1\u{1b}[0m"
.to_string()
);
}

View File

@ -0,0 +1,64 @@
#[cfg(windows)]
mod windows_console {
use std::os::raw::c_void;
#[allow(non_camel_case_types)]
type c_ulong = u32;
#[allow(non_camel_case_types)]
type c_int = i32;
type DWORD = c_ulong;
type LPDWORD = *mut DWORD;
type HANDLE = *mut c_void;
type BOOL = c_int;
const ENABLE_VIRTUAL_TERMINAL_PROCESSING: DWORD = 0x0004;
const STD_OUTPUT_HANDLE: DWORD = 0xFFFFFFF5;
const STD_ERROR_HANDLE: DWORD = 0xFFFFFFF4;
const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
const FALSE: BOOL = 0;
const TRUE: BOOL = 1;
// This is the win32 console API, taken from the 'winapi' crate.
extern "system" {
fn GetStdHandle(nStdHandle: DWORD) -> HANDLE;
fn GetConsoleMode(hConsoleHandle: HANDLE, lpMode: LPDWORD) -> BOOL;
fn SetConsoleMode(hConsoleHandle: HANDLE, dwMode: DWORD) -> BOOL;
}
unsafe fn get_handle(handle_num: DWORD) -> Result<HANDLE, ()> {
match GetStdHandle(handle_num) {
handle if handle == INVALID_HANDLE_VALUE => Err(()),
handle => Ok(handle),
}
}
unsafe fn enable_vt(handle: HANDLE) -> Result<(), ()> {
let mut dw_mode: DWORD = 0;
if GetConsoleMode(handle, &mut dw_mode) == FALSE {
return Err(());
}
dw_mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
match SetConsoleMode(handle, dw_mode) {
result if result == TRUE => Ok(()),
_ => Err(()),
}
}
unsafe fn enable_ansi_colors_raw() -> Result<bool, ()> {
let stdout_handle = get_handle(STD_OUTPUT_HANDLE)?;
let stderr_handle = get_handle(STD_ERROR_HANDLE)?;
enable_vt(stdout_handle)?;
if stdout_handle != stderr_handle {
enable_vt(stderr_handle)?;
}
Ok(true)
}
#[inline]
pub fn enable_ansi_colors() -> bool {
unsafe { enable_ansi_colors_raw().unwrap_or(false) }
}
}

View File

@ -0,0 +1,146 @@
use std::fmt::Display;
use super::Paint;
use unicode_width::UnicodeWidthStr;
/// Text that can be displayed on the terminal, measured, truncated and padded.
pub trait Cell: Display {
/// Type after truncation.
type Truncated: Cell;
/// Type after padding.
type Padded: Cell;
/// Cell display width in number of terminal columns.
fn width(&self) -> usize;
/// Truncate cell if longer than given width. Shows the delimiter if truncated.
fn truncate(&self, width: usize, delim: &str) -> Self::Truncated;
/// Pad the cell so that it is the given width, while keeping the content left-aligned.
fn pad(&self, width: usize) -> Self::Padded;
}
impl Cell for Paint<String> {
type Truncated = Self;
type Padded = Self;
fn width(&self) -> usize {
UnicodeWidthStr::width(self.content())
}
fn truncate(&self, width: usize, delim: &str) -> Self {
Self {
item: self.item.truncate(width, delim),
style: self.style,
}
}
fn pad(&self, width: usize) -> Self {
Self {
item: self.item.pad(width),
style: self.style,
}
}
}
impl Cell for Paint<&str> {
type Truncated = Paint<String>;
type Padded = Paint<String>;
fn width(&self) -> usize {
Cell::width(self.item)
}
fn truncate(&self, width: usize, delim: &str) -> Paint<String> {
Paint {
item: self.item.truncate(width, delim),
style: self.style,
}
}
fn pad(&self, width: usize) -> Paint<String> {
Paint {
item: self.item.pad(width),
style: self.style,
}
}
}
impl Cell for String {
type Truncated = Self;
type Padded = Self;
fn width(&self) -> usize {
Cell::width(self.as_str())
}
fn truncate(&self, width: usize, delim: &str) -> Self {
self.as_str().truncate(width, delim)
}
fn pad(&self, width: usize) -> Self {
self.as_str().pad(width)
}
}
impl Cell for str {
type Truncated = String;
type Padded = String;
fn width(&self) -> usize {
UnicodeWidthStr::width(self)
}
fn truncate(&self, width: usize, delim: &str) -> String {
use unicode_segmentation::UnicodeSegmentation as _;
if width < Cell::width(self) {
let d = Cell::width(delim);
if width < d {
// If we can't even fit the delimiter, just return an empty string.
return String::new();
}
// Find the unicode byte boundary where the display width is the largest,
// while being smaller than the given max width.
let mut cols = 0; // Number of visual columns we need.
let mut boundary = 0; // Boundary in bytes.
for g in self.graphemes(true) {
let c = Cell::width(g);
if cols + c + d > width {
break;
}
boundary += g.len();
cols += c;
}
format!("{}{delim}", &self[..boundary])
} else {
self.to_owned()
}
}
fn pad(&self, max: usize) -> String {
let width = Cell::width(self);
if width < max {
format!("{self}{}", " ".repeat(max - width))
} else {
self.to_owned()
}
}
}
impl<T: Cell + ?Sized> Cell for &T {
type Truncated = T::Truncated;
type Padded = T::Padded;
fn width(&self) -> usize {
T::width(self)
}
fn truncate(&self, width: usize, delim: &str) -> Self::Truncated {
T::truncate(self, width, delim)
}
fn pad(&self, width: usize) -> Self::Padded {
T::pad(self, width)
}
}

View File

@ -0,0 +1,94 @@
use std::ffi::OsString;
use std::io::Write;
use std::path::PathBuf;
use std::process;
use std::{env, fs, io};
pub const COMMENT_FILE: &str = "RAD_COMMENT";
/// Allows for text input in the configured editor.
pub struct Editor {
path: PathBuf,
}
impl Drop for Editor {
fn drop(&mut self) {
fs::remove_file(&self.path).ok();
}
}
impl Default for Editor {
fn default() -> Self {
Self::new()
}
}
impl Editor {
/// Create a new editor.
pub fn new() -> Self {
let path = env::temp_dir().join(COMMENT_FILE);
Self { path }
}
/// Set the file extension.
pub fn extension(mut self, ext: &str) -> Self {
let ext = ext.trim_start_matches('.');
self.path.set_extension(ext);
self
}
/// Open the editor and return the edited text.
///
/// If the text hasn't changed from the initial contents of the editor,
/// return `None`.
pub fn edit(&mut self, initial: impl ToString) -> io::Result<Option<String>> {
let initial = initial.to_string();
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.open(&self.path)?;
if file.metadata()?.len() == 0 {
file.write_all(initial.as_bytes())?;
if !initial.ends_with('\n') {
file.write_all(b"\n")?;
}
file.flush()?;
}
let Some(cmd) = self::default_editor() else {
return Err(
io::Error::new(
io::ErrorKind::NotFound,
"editor not configured: the `EDITOR` environment variable is not set"
)
);
};
process::Command::new(cmd).arg(&self.path).spawn()?.wait()?;
let text = fs::read_to_string(&self.path)?;
let text = text.strip_prefix(&initial).unwrap_or(&text);
if text.trim().is_empty() {
return Ok(None);
}
Ok(Some(text.to_owned()))
}
}
/// Get the default editor command.
pub fn default_editor() -> Option<OsString> {
if let Ok(visual) = env::var("VISUAL") {
if !visual.is_empty() {
return Some(visual.into());
}
}
if let Ok(editor) = env::var("EDITOR") {
if !editor.is_empty() {
return Some(editor.into());
}
}
None
}

View File

@ -1,6 +1,6 @@
use std::{fmt, time}; use std::{fmt, time};
pub use dialoguer::console::style; pub use crate::terminal::{style, Paint};
use radicle::cob::{ObjectId, Timestamp}; use radicle::cob::{ObjectId, Timestamp};
use radicle::node::NodeId; use radicle::node::NodeId;
@ -88,58 +88,74 @@ impl<'a> fmt::Display for Identity<'a> {
} }
} }
pub fn negative<D: std::fmt::Display>(msg: D) -> String { pub fn negative<D: std::fmt::Display>(msg: D) -> Paint<D> {
style(msg).red().bright().to_string() Paint::red(msg).bold()
} }
pub fn positive<D: std::fmt::Display>(msg: D) -> String { pub fn positive<D: std::fmt::Display>(msg: D) -> Paint<D> {
style(msg).green().bright().to_string() Paint::green(msg).bold()
} }
pub fn secondary<D: std::fmt::Display>(msg: D) -> String { pub fn secondary<D: std::fmt::Display>(msg: D) -> Paint<D> {
style(msg).blue().bright().to_string() Paint::blue(msg).bold()
} }
pub fn tertiary<D: std::fmt::Display>(msg: D) -> String { pub fn tertiary<D: std::fmt::Display>(msg: D) -> Paint<D> {
style(msg).cyan().to_string() Paint::cyan(msg)
} }
pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> String { pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> Paint<D> {
style(msg).cyan().bold().to_string() Paint::cyan(msg).bold()
} }
pub fn yellow<D: std::fmt::Display>(msg: D) -> String { pub fn yellow<D: std::fmt::Display>(msg: D) -> Paint<D> {
style(msg).yellow().to_string() Paint::yellow(msg)
} }
pub fn highlight<D: std::fmt::Display>(input: D) -> String { pub fn highlight<D: std::fmt::Debug + std::fmt::Display>(input: D) -> Paint<D> {
style(input).green().bright().to_string() Paint::green(input).bold()
} }
pub fn badge_primary<D: std::fmt::Display>(input: D) -> String { pub fn badge_primary<D: std::fmt::Display>(input: D) -> Paint<String> {
style(format!(" {input} ")).magenta().reverse().to_string() if Paint::is_enabled() {
Paint::magenta(format!(" {input} ")).invert()
} else {
Paint::new(format!("{input}"))
}
} }
pub fn badge_positive<D: std::fmt::Display>(input: D) -> String { pub fn badge_positive<D: std::fmt::Display>(input: D) -> Paint<String> {
style(format!(" {input} ")).green().reverse().to_string() if Paint::is_enabled() {
Paint::green(format!(" {input} ")).invert()
} else {
Paint::new(format!("{input}"))
}
} }
pub fn badge_negative<D: std::fmt::Display>(input: D) -> String { pub fn badge_negative<D: std::fmt::Display>(input: D) -> Paint<String> {
style(format!(" {input} ")).red().reverse().to_string() if Paint::is_enabled() {
Paint::red(format!(" {input} ")).invert()
} else {
Paint::new(format!("{input}"))
}
} }
pub fn badge_secondary<D: std::fmt::Display>(input: D) -> String { pub fn badge_secondary<D: std::fmt::Display>(input: D) -> Paint<String> {
style(format!(" {input} ")).blue().reverse().to_string() if Paint::is_enabled() {
Paint::blue(format!(" {input} ")).invert()
} else {
Paint::new(format!("{input}"))
}
} }
pub fn bold<D: std::fmt::Display>(input: D) -> String { pub fn bold<D: std::fmt::Display>(input: D) -> Paint<D> {
style(input).white().bright().bold().to_string() Paint::white(input).bold()
} }
pub fn dim<D: std::fmt::Display>(input: D) -> String { pub fn dim<D: std::fmt::Display>(input: D) -> Paint<D> {
style(input).dim().to_string() Paint::new(input).dim()
} }
pub fn italic<D: std::fmt::Display>(input: D) -> String { pub fn italic<D: std::fmt::Display>(input: D) -> Paint<D> {
style(input).italic().dim().to_string() Paint::new(input).italic().dim()
} }

View File

@ -1,24 +1,39 @@
use std::fmt; use std::fmt;
use std::str::FromStr;
use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password}; use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled};
use inquire::InquireError;
use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password, Select};
use once_cell::sync::Lazy;
use radicle::cob::issue::Issue; use radicle::cob::issue::Issue;
use radicle::cob::thread::CommentId; use radicle::cob::thread::{Comment, CommentId};
use radicle::crypto::ssh::keystore::Passphrase; use radicle::crypto::ssh::keystore::{MemorySigner, Passphrase};
use radicle::crypto::Signer; use radicle::crypto::Signer;
use radicle::profile; use radicle::profile;
use radicle::profile::Profile; use radicle::profile::Profile;
use radicle_crypto::ssh::keystore::MemorySigner;
use super::command; use super::command;
use super::format; use super::format;
use super::spinner::spinner; use super::spinner::spinner;
use super::Error; use super::Error;
use super::{style, Paint};
pub const ERROR_PREFIX: Paint<&str> = Paint::red("");
pub const TAB: &str = " "; pub const TAB: &str = " ";
/// Render configuration.
pub static CONFIG: Lazy<RenderConfig> = Lazy::new(|| RenderConfig {
prompt: StyleSheet::new().with_fg(Color::LightCyan),
prompt_prefix: Styled::new("?").with_fg(Color::LightBlue),
answered_prompt_prefix: Styled::new("").with_fg(Color::LightGreen),
answer: StyleSheet::new(),
highlighted_option_prefix: Styled::new("*").with_fg(Color::LightYellow),
help_message: StyleSheet::new().with_fg(Color::DarkGrey),
error_message: ErrorMessageRenderConfig::default_colored()
.with_prefix(Styled::new("").with_fg(Color::LightRed)),
..RenderConfig::default_colored()
});
#[macro_export] #[macro_export]
macro_rules! info { macro_rules! info {
($($arg:tt)*) => ({ ($($arg:tt)*) => ({
@ -45,17 +60,15 @@ pub use success;
pub use tip; pub use tip;
pub fn success_args(args: fmt::Arguments) { pub fn success_args(args: fmt::Arguments) {
println!("{} {args}", style("ok").green().reverse()); println!("{} {args}", Paint::green(""));
} }
pub fn tip_args(args: fmt::Arguments) { pub fn tip_args(args: fmt::Arguments) {
println!("{} {}", style("=>").blue(), style(format!("{args}")).dim()); println!("👉 {}", style(format!("{args}")).italic());
} }
pub fn width() -> Option<usize> { pub fn columns() -> Option<usize> {
console::Term::stdout() termion::terminal_size().map(|(cols, _)| cols as usize).ok()
.size_checked()
.map(|(_, cols)| cols as usize)
} }
pub fn headline(headline: &str) { pub fn headline(headline: &str) {
@ -66,7 +79,7 @@ pub fn headline(headline: &str) {
pub fn header(header: &str) { pub fn header(header: &str) {
println!(); println!();
println!("{}", style(format::yellow(header)).bold().underlined()); println!("{}", style(format::yellow(header)).bold().underline());
println!(); println!();
} }
@ -95,9 +108,9 @@ pub fn help(name: &str, version: &str, description: &str, usage: &str) {
pub fn usage(name: &str, usage: &str) { pub fn usage(name: &str, usage: &str) {
println!( println!(
"{} {}\n{}", "{} {}\n{}",
style("==").red(), ERROR_PREFIX,
style(format!("Error: rad-{name}: invalid usage")).red(), Paint::red(format!("Error: rad-{name}: invalid usage")),
style(prefixed(TAB, usage)).red().dim() Paint::red(prefixed(TAB, usage)).dim()
); );
} }
@ -115,57 +128,39 @@ pub fn subcommand(msg: impl fmt::Display) {
pub fn warning(warning: &str) { pub fn warning(warning: &str) {
println!( println!(
"{} {} {}", "{} {} {warning}",
style("**").yellow(), Paint::yellow("!"),
style("Warning:").yellow().bold(), Paint::yellow("Warning:").bold(),
style(warning).yellow()
); );
} }
pub fn error(error: impl fmt::Display) { pub fn error(error: impl fmt::Display) {
println!("{} {}", style("==").red(), style(error).red()); println!("{ERROR_PREFIX} {error}");
} }
pub fn fail(header: &str, error: &anyhow::Error) { pub fn fail(header: &str, error: &anyhow::Error) {
let err = error.to_string(); let err = error.to_string();
let err = err.trim_end(); let err = err.trim_end();
let separator = if err.len() > 160 || err.contains('\n') { let separator = if err.contains('\n') { ":\n" } else { ": " };
"\n"
} else {
" "
};
println!( println!(
"{} {}{}{}", "{ERROR_PREFIX} {}{}{error}",
style("==").red(), Paint::red(header).bold(),
style(header).red().reverse(), Paint::red(separator),
separator,
style(error).red().bold(),
); );
let cause = error.root_cause();
if cause.to_string() != error.to_string() {
println!(
"{} {}",
style("==").red().dim(),
style(error.root_cause()).red().dim()
);
blank();
}
if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::<Error>() { if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::<Error>() {
println!("{} {}", style("==").yellow(), style(hint).yellow(),); println!("{} {}", Paint::yellow("×"), Paint::yellow(hint));
blank(); blank();
} }
} }
pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool { pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
dialoguer::Confirm::new() let prompt = format!("{} {}", Paint::blue("?".to_owned()), prompt);
.with_prompt(format!("{} {}", style("".to_owned()).cyan(), prompt))
.wait_for_newline(false) Confirm::new(&prompt)
.default(true) .with_default(default)
.default(default) .prompt()
.interact()
.unwrap_or_default() .unwrap_or_default()
} }
@ -182,209 +177,95 @@ pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
if let Ok(signer) = profile.signer() { if let Ok(signer) = profile.signer() {
return Ok(signer); return Ok(signer);
} }
let passphrase = passphrase()?;
let passphrase = secret_input();
let spinner = spinner("Unsealing key..."); let spinner = spinner("Unsealing key...");
let signer = MemorySigner::load(&profile.keystore, passphrase)?; let signer = MemorySigner::load(&profile.keystore, passphrase)?;
spinner.finish(); spinner.finish();
Ok(signer.boxed()) Ok(signer.boxed())
} }
pub fn theme() -> ColorfulTheme { pub fn input<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
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 where
S: fmt::Display + std::str::FromStr<Err = E> + Clone, S: fmt::Display + std::str::FromStr<Err = E> + Clone,
E: fmt::Debug + fmt::Display, E: fmt::Debug + fmt::Display,
{ {
let theme = theme(); let input = CustomType::<S>::new(message).with_render_config(*CONFIG);
let mut input: Input<S> = Input::with_theme(&theme);
let value = match default { let value = match default {
Some(default) => input Some(default) => input.with_default(default).prompt()?,
.with_prompt(message) None => input.prompt()?,
.with_initial_text(default.to_string())
.interact_text()?,
None => input.with_prompt(message).interact_text()?,
}; };
Ok(value) Ok(value)
} }
#[derive(Debug, Default, Clone)] pub fn passphrase() -> Result<Passphrase, anyhow::Error> {
pub struct Optional<T> { if let Some(p) = profile::env::passphrase() {
option: Option<T>, Ok(p)
} } else {
Ok(Passphrase::from(
impl<T: fmt::Display> fmt::Display for Optional<T> { Password::new("Passphrase:")
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { .with_render_config(*CONFIG)
if let Some(val) = &self.option { .with_display_mode(inquire::PasswordDisplayMode::Masked)
write!(f, "{val}") .without_confirmation()
} else { .prompt()?,
write!(f, "") ))
}
} }
} }
impl<T: FromStr> FromStr for Optional<T> { pub fn passphrase_confirm() -> Result<Passphrase, anyhow::Error> {
type Err = <T as FromStr>::Err; if let Some(p) = profile::env::passphrase() {
Ok(p)
fn from_str(s: &str) -> Result<Self, Self::Err> { } else {
if s.is_empty() { Ok(Passphrase::from(
return Ok(Optional { option: None }); Password::new("Passphrase:")
} .with_render_config(*CONFIG)
let val: T = s.parse()?; .with_display_mode(inquire::PasswordDisplayMode::Masked)
.with_custom_confirmation_message("Repeat passphrase:")
Ok(Self { option: Some(val) }) .with_custom_confirmation_error_message("The passphrases don't match.")
.prompt()?,
))
} }
} }
pub fn text_input_optional<S, E>( pub fn passphrase_stdin() -> Result<Passphrase, anyhow::Error> {
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(); let mut input = String::new();
std::io::stdin().read_line(&mut input)?; std::io::stdin().read_line(&mut input)?;
Ok(Passphrase::from(input.trim_end().to_owned())) Ok(Passphrase::from(input.trim_end().to_owned()))
} }
pub fn read_passphrase(stdin: bool, confirm: bool) -> Result<Passphrase, anyhow::Error> { pub fn select<'a, T>(
let passphrase = match profile::env::read_passphrase() { prompt: &str,
Some(input) => input, options: &'a [T],
None => { active: &'a T,
if stdin { ) -> Result<Option<&'a T>, InquireError>
secret_stdin()?
} else if confirm {
secret_input_with_confirmation()
} else {
secret_input()
}
}
};
Ok(passphrase)
}
pub fn select<'a, T>(options: &'a [T], active: &'a T) -> Option<&'a T>
where where
T: fmt::Display + Eq + PartialEq, T: fmt::Display + Eq + PartialEq,
{ {
let theme = theme();
let active = options.iter().position(|o| o == active); let active = options.iter().position(|o| o == active);
let mut selection = dialoguer::Select::with_theme(&theme); let selection =
Select::new(prompt, options.iter().collect::<Vec<_>>()).with_render_config(*CONFIG);
if let Some(active) = active { if let Some(active) = active {
selection.default(active); selection.with_starting_cursor(active).prompt_skippable()
} else {
selection.prompt_skippable()
} }
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> pub fn comment_select(issue: &Issue) -> Option<(&CommentId, &Comment)> {
where let comments = issue.comments().collect::<Vec<_>>();
T: fmt::Display + Eq + PartialEq, let selection = Select::new(
{ "Which comment do you want to react to?",
let theme = theme(); (0..comments.len()).collect(),
let active = options.iter().position(|o| o == active); )
let mut selection = dialoguer::Select::with_theme(&theme); .with_render_config(*CONFIG)
.with_formatter(&|i| comments[i.index].1.body().to_owned())
.prompt()
.ok()?;
selection.with_prompt(prompt); comments.get(selection).copied()
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 comment_select(issue: &Issue) -> Option<CommentId> {
let selection = dialoguer::Select::with_theme(&theme())
.with_prompt("Which comment do you want to react to?")
.item(issue.description().unwrap_or_default())
.items(
&issue
.comments()
.map(|(_, i)| i.body().to_owned())
.collect::<Vec<_>>(),
)
.default(0)
.interact_opt()
.unwrap();
selection
.and_then(|n| issue.comments().nth(n))
.map(|(id, _)| *id)
} }
pub fn markdown(content: &str) { pub fn markdown(content: &str) {
@ -393,10 +274,6 @@ pub fn markdown(content: &str) {
} }
} }
fn _info(args: std::fmt::Arguments) {
println!("{args}");
}
pub mod proposal { pub mod proposal {
use std::fmt::Write as _; use std::fmt::Write as _;
@ -406,44 +283,44 @@ pub mod proposal {
identity::Identity, identity::Identity,
}; };
use super::{super::format, theme}; use super::*;
use crate::terminal::format;
pub fn revision_select( pub fn revision_select(
proposal: &Proposal, proposal: &Proposal,
) -> Option<(&identity::RevisionId, &identity::Revision)> { ) -> Option<(&identity::RevisionId, &identity::Revision)> {
let selection = dialoguer::Select::with_theme(&theme()) let revisions = proposal.revisions().collect::<Vec<_>>();
.with_prompt("Which revision do you want to select?") let selection = Select::new(
.items( "Which revision do you want to select?",
&proposal (0..revisions.len()).collect(),
.revisions() )
.map(|(rid, _)| rid.to_string()) .with_vim_mode(true)
.collect::<Vec<_>>(), .with_formatter(&|ix| revisions[ix.index].0.to_string())
) .with_render_config(*CONFIG)
.default(0) .prompt()
.interact_opt() .ok()?;
.unwrap();
selection.and_then(|n| proposal.revisions().nth(n)) revisions.get(selection).copied()
} }
pub fn revision_commit_select<'a>( pub fn revision_commit_select<'a>(
proposal: &'a Proposal, proposal: &'a Proposal,
previous: &'a Identity<Oid>, previous: &'a Identity<Oid>,
) -> Option<(&'a identity::RevisionId, &'a identity::Revision)> { ) -> Option<(&'a identity::RevisionId, &'a identity::Revision)> {
let selection = dialoguer::Select::with_theme(&theme()) let revisions = proposal
.with_prompt("Which revision do you want to commit?") .revisions()
.items( .filter(|(_, r)| r.is_quorum_reached(previous))
&proposal .collect::<Vec<_>>();
.revisions() let selection = Select::new(
.filter(|(_, r)| r.is_quorum_reached(previous)) "Which revision do you want to commit?",
.map(|(rid, _)| rid.to_string()) (0..revisions.len()).collect(),
.collect::<Vec<_>>(), )
) .with_formatter(&|ix| revisions[ix.index].0.to_string())
.default(0) .with_render_config(*CONFIG)
.interact_opt() .prompt()
.unwrap(); .ok()?;
selection.and_then(|n| proposal.revisions().nth(n)) revisions.get(selection).copied()
} }
pub fn diff(proposal: &identity::Revision, previous: &Identity<Oid>) -> anyhow::Result<String> { pub fn diff(proposal: &identity::Revision, previous: &Identity<Oid>) -> anyhow::Result<String> {

View File

@ -1,6 +1,7 @@
use radicle::git; use radicle::git;
use crate::terminal as term; use crate::terminal as term;
use crate::terminal::cell::Cell as _;
/// The user supplied `Patch` description. /// The user supplied `Patch` description.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -18,15 +19,14 @@ impl Message {
pub fn get(self, help: &str) -> String { pub fn get(self, help: &str) -> String {
let comment = match self { let comment = match self {
Message::Edit => term::Editor::new() Message::Edit => term::Editor::new()
.require_save(true) .extension("markdown")
.trim_newlines(true)
.extension(".markdown")
.edit(help) .edit(help)
.unwrap(), .ok()
.flatten(),
Message::Blank => None, Message::Blank => None,
Message::Text(c) => Some(c), Message::Text(c) => Some(c),
}; };
let comment = comment.unwrap_or_default().replace(help, ""); let comment = comment.unwrap_or_default();
let comment = comment.trim(); let comment = comment.trim();
comment.to_owned() comment.to_owned()
@ -57,7 +57,7 @@ pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
.unwrap_or_else(|| commit.message_bytes()); .unwrap_or_else(|| commit.message_bytes());
table.push([ table.push([
term::format::secondary(term::format::oid(commit.id())), term::format::secondary(term::format::oid(commit.id())),
term::format::italic(String::from_utf8_lossy(message)), term::format::italic(String::from_utf8_lossy(message).to_string()),
]); ]);
} }
table.render(); table.render();
@ -100,6 +100,6 @@ pub fn print_title_desc(title: &str, description: &str) {
term::blank(); term::blank();
term::print(term::format::dim(format!( term::print(term::format::dim(format!(
"╰{}", "╰{}",
"".repeat(term::text_width(title_pretty) - 1) "".repeat(title_pretty.to_string().width() - 1)
))); )));
} }

View File

@ -1,77 +1,171 @@
use dialoguer::console::style; use std::io::Write;
use indicatif::{ProgressBar, ProgressFinish, ProgressStyle}; use std::mem::ManuallyDrop;
use std::sync::{Arc, Mutex};
use std::{fmt, io, thread, time};
use crate::terminal as term; use crate::terminal::io::ERROR_PREFIX;
use crate::terminal::Paint;
pub struct Spinner { /// How much time to wait between spinner animation updates.
progress: ProgressBar, pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(99);
message: String, /// The spinner animation strings.
pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [
Paint::magenta(""),
Paint::cyan(""),
Paint::magenta(""),
Paint::blue(""),
];
struct Progress {
state: State,
message: Paint<String>,
} }
impl Drop for Spinner { impl Progress {
fn drop(&mut self) { fn new(message: Paint<String>) -> Self {
// TODO: Set error that will be output on fail. Self {
if !self.progress.is_finished() { state: State::Running { cursor: 0 },
self.set_failed(); message,
} }
} }
} }
impl Spinner { enum State {
pub fn finish(&self) { Running { cursor: usize },
self.progress.finish_and_clear(); Canceled,
term::success!("{}", &self.message); Done,
} Error,
}
pub fn done(self) { /// A progress spinner.
self.progress.finish_and_clear(); pub struct Spinner {
term::info!("{}", &self.message); progress: Arc<Mutex<Progress>>,
} handle: ManuallyDrop<thread::JoinHandle<()>>,
}
pub fn failed(mut self) { impl Drop for Spinner {
self.set_failed(); fn drop(&mut self) {
} if let Ok(mut progress) = self.progress.lock() {
if let State::Running { .. } = progress.state {
pub fn error(mut self, msg: impl ToString) { progress.state = State::Canceled;
let msg = msg.to_string(); }
}
self.message = format!("{} error: {}", self.message, msg); unsafe { ManuallyDrop::take(&mut self.handle) }
self.set_failed(); .join()
} .unwrap();
pub fn clear(self) {
self.progress.finish_and_clear();
}
pub fn message(&mut self, msg: impl ToString) {
let msg = msg.to_string();
self.progress.set_message(msg.clone());
self.message = msg;
}
pub fn set_failed(&mut self) {
self.progress.finish_and_clear();
term::println(style("!!").red().reverse(), &self.message);
} }
} }
impl Spinner {
/// Mark the spinner as successfully completed.
pub fn finish(self) {
if let Ok(mut progress) = self.progress.lock() {
progress.state = State::Done;
}
}
/// Mark the spinner as failed. This cancels the spinner.
pub fn failed(self) {
if let Ok(mut progress) = self.progress.lock() {
progress.state = State::Error;
}
}
/// Cancel the spinner with an error.
pub fn error(self, msg: impl fmt::Display) {
if let Ok(mut progress) = self.progress.lock() {
progress.state = State::Error;
progress.message = Paint::new(format!(
"{} {} {}",
progress.message,
Paint::red("error:"),
msg
));
}
}
/// Set the spinner's message.
pub fn message(&mut self, msg: impl fmt::Display) {
let msg = msg.to_string();
if let Ok(mut progress) = self.progress.lock() {
progress.message = Paint::new(msg);
}
}
}
/// Create a new spinner with the given message.
pub fn spinner(message: impl ToString) -> Spinner { pub fn spinner(message: impl ToString) -> Spinner {
let message = message.to_string(); let message = message.to_string();
let style = ProgressStyle::default_spinner() let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message))));
.tick_strings(&[ let handle = thread::spawn({
&style("\\ ").yellow().to_string(), let progress = progress.clone();
&style("| ").yellow().to_string(),
&style("/ ").yellow().to_string(),
&style("| ").yellow().to_string(),
])
.template("{spinner} {msg}")
.on_finish(ProgressFinish::AndClear);
let progress = ProgressBar::new(!0); move || {
progress.set_style(style); let mut stdout = io::stdout();
progress.enable_steady_tick(99); let mut stderr = termion::cursor::HideCursor::from(io::stderr());
progress.set_message(message.clone());
Spinner { message, progress } loop {
let Ok(mut progress) = progress.lock() else {
break;
};
match &mut *progress {
Progress {
state: State::Running { cursor },
message,
} => {
let spinner = DEFAULT_STYLE[*cursor];
write!(
stderr,
"{}{}{spinner} {message}",
termion::cursor::Save,
termion::clear::AfterCursor,
)
.ok();
write!(stderr, "{}", termion::cursor::Restore).ok();
*cursor += 1;
*cursor %= DEFAULT_STYLE.len();
}
Progress {
state: State::Done,
message,
} => {
write!(stderr, "{}", termion::clear::AfterCursor).ok();
writeln!(stdout, "{} {message}", Paint::green("")).ok();
break;
}
Progress {
state: State::Canceled,
message,
} => {
write!(stderr, "{}", termion::clear::AfterCursor).ok();
writeln!(
stdout,
"{ERROR_PREFIX} {message} {}",
Paint::red("<canceled>")
)
.ok();
break;
}
Progress {
state: State::Error,
message,
} => {
writeln!(stdout, "{ERROR_PREFIX} {message}").ok();
break;
}
}
drop(progress);
thread::sleep(DEFAULT_TICK);
}
}
});
Spinner {
progress,
handle: ManuallyDrop::new(handle),
}
} }

View File

@ -9,18 +9,31 @@
//! t.push(["aphid", "lacewing"]); //! t.push(["aphid", "lacewing"]);
//! t.push(["spider mite", "ladybug"]); //! t.push(["spider mite", "ladybug"]);
//! t.render(); //! t.render();
//! // pest biological control
//! // aphid ladybug
//! // spider mite persimilis
//! ``` //! ```
//! Output:
use std::fmt::Write; //! ``` plain
//! pest biological control
//! aphid ladybug
//! spider mite persimilis
//! ```
use std::io;
use crate::terminal as term; use crate::terminal as term;
use crate::terminal::cell::Cell;
/// Used to specify maximum width or height.
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
pub struct Max {
width: Option<usize>,
height: Option<usize>,
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct TableOptions { pub struct TableOptions {
/// Whether the table should be allowed to overflow.
pub overflow: bool, pub overflow: bool,
/// The maximum width and height.
pub max: Max,
} }
#[derive(Debug)] #[derive(Debug)]
@ -49,16 +62,20 @@ impl<const W: usize> Table<W> {
} }
} }
pub fn push(&mut self, row: [impl ToString; W]) { pub fn push(&mut self, row: [impl Cell; W]) {
let row = row.map(|s| s.to_string()); let row = row.map(|s| s.to_string());
for (i, cell) in row.iter().enumerate() { for (i, cell) in row.iter().enumerate() {
self.widths[i] = self.widths[i].max(console::measure_text_width(cell)); self.widths[i] = self.widths[i].max(cell.width());
} }
self.rows.push(row); self.rows.push(row);
} }
pub fn render(self) { pub fn render(self) {
let width = term::width(); // Terminal width. self.write(io::stdout()).ok();
}
pub fn write<T: io::Write>(self, mut writer: T) -> io::Result<()> {
let width = self.opts.max.width.or_else(term::columns);
for row in &self.rows { for row in &self.rows {
let mut output = String::new(); let mut output = String::new();
@ -66,27 +83,25 @@ impl<const W: usize> Table<W> {
for (i, cell) in row.iter().enumerate() { for (i, cell) in row.iter().enumerate() {
if i == cells - 1 || self.opts.overflow { if i == cells - 1 || self.opts.overflow {
write!(output, "{cell}").ok(); output.push_str(cell.to_string().as_str());
} else { } else {
write!( output.push_str(cell.pad(self.widths[i]).as_str());
output, output.push(' ');
"{} ",
console::pad_str(cell, self.widths[i], console::Alignment::Left, None)
)
.ok();
} }
} }
let output = output.trim_end(); let output = output.trim_end();
println!( writeln!(
writer,
"{}", "{}",
if let Some(width) = width { if let Some(width) = width {
console::truncate_str(output, width - 1, "") output.truncate(width, "")
} else { } else {
output.into() output.into()
} }
); )?;
} }
Ok(())
} }
pub fn render_tree(self) { pub fn render_tree(self) {
@ -97,12 +112,116 @@ impl<const W: usize> Table<W> {
print!("└── "); print!("└── ");
} }
for (i, cell) in row.iter().enumerate() { for (i, cell) in row.iter().enumerate() {
print!( print!("{} ", cell.pad(self.widths[i]));
"{} ",
console::pad_str(cell, self.widths[i], console::Alignment::Left, None)
);
} }
println!(); println!();
} }
} }
} }
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_truncate() {
assert_eq!("🍍".truncate(1, ""), String::from(""));
assert_eq!("🍍".truncate(1, ""), String::from(""));
assert_eq!("🍍🍍".truncate(2, ""), String::from(""));
assert_eq!("🍍🍍".truncate(3, ""), String::from("🍍…"));
assert_eq!("🍍".truncate(1, "🍎"), String::from(""));
assert_eq!("🍍".truncate(2, "🍎"), String::from("🍍"));
assert_eq!("🍍🍍".truncate(3, "🍎"), String::from("🍎"));
assert_eq!("🍍🍍🍍".truncate(4, "🍎"), String::from("🍍🍎"));
assert_eq!("hello".truncate(3, ""), String::from("he…"));
}
#[test]
fn test_table() {
let mut s = Vec::new();
let mut t = Table::new(TableOptions::default());
t.push(["pineapple", "rosemary"]);
t.push(["apples", "pears"]);
t.write(&mut s).unwrap();
#[rustfmt::skip]
assert_eq!(
String::from_utf8_lossy(&s),
[
"pineapple rosemary\n",
"apples pears\n"
].join("")
);
}
#[test]
fn test_table_truncate() {
let mut s = Vec::new();
let mut t = Table::new(TableOptions {
max: Max {
width: Some(16),
height: None,
},
..TableOptions::default()
});
t.push(["pineapple", "rosemary"]);
t.push(["apples", "pears"]);
t.write(&mut s).unwrap();
#[rustfmt::skip]
assert_eq!(
String::from_utf8_lossy(&s),
[
"pineapple rosem…\n",
"apples pears\n"
].join("")
);
}
#[test]
fn test_table_unicode() {
let mut s = Vec::new();
let mut t = Table::new(TableOptions::default());
t.push(["🍍pineapple", "__rosemary", "__sage"]);
t.push(["__pears", "🍎apples", "🍌bananas"]);
t.write(&mut s).unwrap();
#[rustfmt::skip]
assert_eq!(
String::from_utf8_lossy(&s),
[
"🍍pineapple __rosemary __sage\n",
"__pears 🍎apples 🍌bananas\n"
].join("")
);
}
#[test]
fn test_table_unicode_truncate() {
let mut s = Vec::new();
let mut t = Table::new(TableOptions {
max: Max {
width: Some(16),
height: None,
},
..TableOptions::default()
});
t.push(["🍍pineapple", "__rosemary"]);
t.push(["__pears", "🍎apples"]);
t.write(&mut s).unwrap();
#[rustfmt::skip]
assert_eq!(
String::from_utf8_lossy(&s),
[
"🍍pineapple __r…\n",
"__pears 🍎a…\n"
].join("")
);
}
}

View File

@ -1,6 +1,7 @@
use std::fmt; use std::fmt;
use crate::terminal as term; use crate::terminal as term;
use crate::terminal::cell::Cell as _;
pub struct TextBox { pub struct TextBox {
pub body: String, pub body: String,
@ -32,14 +33,8 @@ impl TextBox {
impl fmt::Display for TextBox { impl fmt::Display for TextBox {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut width = self let mut width = self.body.lines().map(|l| l.width()).max().unwrap_or(0) + 2;
.body if let Some(max) = term::columns() {
.lines()
.map(console::measure_text_width)
.max()
.unwrap_or(0)
+ 2;
if let Some(max) = term::width() {
if width + 2 > max { if width + 2 > max {
width = max - 2 width = max - 2
} }
@ -53,11 +48,7 @@ impl fmt::Display for TextBox {
writeln!(f, "┌{}{}┐", connector, "".repeat(header_width))?; writeln!(f, "┌{}{}┐", connector, "".repeat(header_width))?;
for l in self.body.lines() { for l in self.body.lines() {
writeln!( writeln!(f, "│ {}│", l.pad(width - 1))?;
f,
"│ {}│",
console::pad_str(l, width - 1, console::Alignment::Left, Some(""))
)?;
} }
let (connector, footer_width) = if !self.last { let (connector, footer_width) = if !self.last {

View File

@ -13,6 +13,10 @@ git-ref-format = { version = "0", features = ["serde", "macro"] }
version = "0" version = "0"
path = "../radicle" path = "../radicle"
[dependencies.radicle-cli]
version = "0"
path = "../radicle-cli"
[[bin]] [[bin]]
name = "rad-init" name = "rad-init"
path = "src/rad-init.rs" path = "src/rad-init.rs"
@ -32,3 +36,7 @@ path = "src/rad-push.rs"
[[bin]] [[bin]]
name = "rad-agent" name = "rad-agent"
path = "src/rad-agent.rs" path = "src/rad-agent.rs"
[[bin]]
name = "rad-cli-demo"
path = "src/rad-cli-demo.rs"

View File

@ -0,0 +1,74 @@
use std::{thread, time};
use radicle_cli::terminal;
fn main() -> anyhow::Result<()> {
let demo = terminal::io::select(
"Choose something to try out:",
&[
"spinner",
"spinner-drop",
"spinner-error",
"editor",
"prompt",
],
&"spinner",
)?;
match demo {
Some(&"editor") => {
let output = terminal::editor::Editor::new()
.extension("rs")
.edit("// Enter code here.");
match output {
Ok(Some(s)) => {
terminal::info!("You entered:");
terminal::blob(s);
}
Ok(None) => {
terminal::info!("You didn't enter anything.");
}
Err(e) => {
return Err(e.into());
}
}
}
Some(&"spinner") => {
let mut spinner = terminal::spinner("Spinning turbines..");
thread::sleep(time::Duration::from_secs(1));
spinner.message("Still spinning..");
thread::sleep(time::Duration::from_secs(1));
spinner.message("Almost done..");
thread::sleep(time::Duration::from_secs(1));
spinner.message("Done.");
spinner.finish();
}
Some(&"spinner-drop") => {
let _spinner = terminal::spinner("Spinning turbines..");
thread::sleep(time::Duration::from_secs(3));
}
Some(&"spinner-error") => {
let spinner = terminal::spinner("Spinning turbines..");
thread::sleep(time::Duration::from_secs(3));
spinner.error("broken turbine");
}
Some(&"prompt") => {
let fruit = terminal::io::select(
"Enter your favorite fruit:",
&["apple", "pear", "banana", "strawberry"],
&"apple",
)?;
if let Some(fruit) = fruit {
terminal::success!("You have chosen '{fruit}'");
} else {
terminal::info!("Ok, bye.");
}
}
_ => {}
}
Ok(())
}

View File

@ -178,12 +178,12 @@ impl Project {
} }
#[inline] #[inline]
pub fn name(&self) -> &String { pub fn name(&self) -> &str {
&self.name &self.name
} }
#[inline] #[inline]
pub fn description(&self) -> &String { pub fn description(&self) -> &str {
&self.description &self.description
} }

View File

@ -34,7 +34,7 @@ pub mod env {
/// Passphrase for the encrypted radicle secret key. /// Passphrase for the encrypted radicle secret key.
pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE"; pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE";
pub fn read_passphrase() -> Option<super::Passphrase> { pub fn passphrase() -> Option<super::Passphrase> {
let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else { let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else {
return None; return None;
}; };
@ -109,7 +109,7 @@ impl Profile {
} }
pub fn signer(&self) -> Result<Box<dyn Signer>, Error> { pub fn signer(&self) -> Result<Box<dyn Signer>, Error> {
if let Some(passphrase) = env::read_passphrase() { if let Some(passphrase) = env::passphrase() {
let signer = keystore::MemorySigner::load(&self.keystore, passphrase)?; let signer = keystore::MemorySigner::load(&self.keystore, passphrase)?;
return Ok(signer.boxed()); return Ok(signer.boxed());
} }