rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood551132a51be54ebfbf50ef849c5e81371e5720c2
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "1c5cbf0f8cd96eff79ca270afd40736edc557cfc",
"author": {
"id": "did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"alias": "erikli"
},
"title": "cli: Add optional notification selector call via `rad-tui`",
"state": {
"status": "archived",
"conflicts": []
},
"before": "ea69168f44b1b5a1cfde6e84def408895f6c0b65",
"after": "551132a51be54ebfbf50ef849c5e81371e5720c2",
"commits": [
"551132a51be54ebfbf50ef849c5e81371e5720c2",
"7199932dc728402aa73bde092e86dc5b71aaaa88",
"98ea75940e110ad5bfe45c48f904848622d485a0",
"a15267fe2eb426aff51fba0c2ea62f2c6cf75159"
],
"target": "919d5c780c4da0b5b9f18f1549bc59dffd7125af",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "1c5cbf0f8cd96eff79ca270afd40736edc557cfc",
"author": {
"id": "did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"alias": "erikli"
},
"description": "",
"base": "f15afa84be7bda050bfb93e321bbce28b56d10b6",
"oid": "ccc92dfa433824a0f287f295f5fd57df5bc63b91",
"timestamp": 1711023757
},
{
"id": "edd10e588b62e37db28a06a1a8ff6315a5b591ea",
"author": {
"id": "did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"alias": "erikli"
},
"description": "Rebased",
"base": "ea69168f44b1b5a1cfde6e84def408895f6c0b65",
"oid": "551132a51be54ebfbf50ef849c5e81371e5720c2",
"timestamp": 1712158498
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "0ac8bf67-3de8-4267-b88e-47414ecbcaac"
},
"info_url": "https://cci.rad.levitte.org//0ac8bf67-3de8-4267-b88e-47414ecbcaac.html"
}
Started at: 2026-09-07 16:15:50.668552+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/0ac8bf67-3de8-4267-b88e-47414ecbcaac/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 189 issues · 37 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 1c5cbf0f8cd96eff79ca270afd40736edc557cfc
✓ Switched to branch patch/1c5cbf0 at revision edd10e5
✓ Branch patch/1c5cbf0 setup to track rad/patches/1c5cbf0f8cd96eff79ca270afd40736edc557cfc
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout 551132a51be54ebfbf50ef849c5e81371e5720c2
HEAD is now at 551132a5 cli: Add optional notification selector call via rad-tui
Exit code: 0
$ rad patch show 1c5cbf0f8cd96eff79ca270afd40736edc557cfc -p
╭─────────────────────────────────────────────────────────────────────────────╮
│ Title cli: Add optional notification selector call via `rad-tui` │
│ Patch 1c5cbf0f8cd96eff79ca270afd40736edc557cfc │
│ Author erikli z6MkgFq…FGAnBGz │
│ Head 551132a51be54ebfbf50ef849c5e81371e5720c2 │
│ Base ea69168f44b1b5a1cfde6e84def408895f6c0b65 │
│ Target master │
│ Branches patch/1c5cbf0 │
│ Commits ahead 4, behind 1457 │
│ Status archived │
├─────────────────────────────────────────────────────────────────────────────┤
│ 551132a cli: Add optional notification selector call via rad-tui │
│ 7199932 cli: Add optional patch selection TUI │
│ 98ea759 cli: Add TUI module w/ patch id and operation selection │
│ a15267f term: Add support for TUI commands │
├─────────────────────────────────────────────────────────────────────────────┤
│ ● Revision 1c5cbf0 @ f15afa8..ccc92df by erikli z6MkgFq…FGAnBGz 2 years ago │
│ ↑ Revision edd10e5 @ ea69168..551132a by erikli z6MkgFq…FGAnBGz 2 years ago │
╰─────────────────────────────────────────────────────────────────────────────╯
commit 551132a51be54ebfbf50ef849c5e81371e5720c2
Author: Erik Kundt <erik@zirkular.io>
Date: Fri Feb 9 17:58:36 2024 +0100
cli: Add optional notification selector call via rad-tui
diff --git a/radicle-cli/src/commands/inbox.rs b/radicle-cli/src/commands/inbox.rs
index 7d77d188c..5525e5eac 100644
--- a/radicle-cli/src/commands/inbox.rs
+++ b/radicle-cli/src/commands/inbox.rs
@@ -16,11 +16,14 @@ use radicle::prelude::{Profile, RepoId};
use radicle::storage::{BranchName, ReadRepository, ReadStorage};
use radicle::{cob, git, Storage};
+use radicle_term::Interactive;
use term::Element as _;
use crate::terminal as term;
use crate::terminal::args;
use crate::terminal::args::{Args, Error, Help};
+use crate::terminal::command::CommandError;
+use crate::tui;
pub const HELP: Help = Help {
name: "inbox",
@@ -58,11 +61,26 @@ Options
#[derive(Debug, Default, PartialEq, Eq)]
enum Operation {
#[default]
+ Default,
List,
Show,
Clear,
}
+impl TryFrom<&str> for Operation {
+ type Error = anyhow::Error;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ match value {
+ "default" => Ok(Operation::Default),
+ "list" => Ok(Operation::List),
+ "show" => Ok(Operation::Show),
+ "clear" => Ok(Operation::Clear),
+ _ => Err(anyhow!("invalid operation name: {value}")),
+ }
+ }
+}
+
#[derive(Default, Debug)]
enum Mode {
#[default]
@@ -73,9 +91,9 @@ enum Mode {
}
#[derive(Clone, Copy, Debug)]
-struct SortBy {
- reverse: bool,
- field: &'static str,
+pub struct SortBy {
+ pub reverse: bool,
+ pub field: &'static str,
}
pub struct Options {
@@ -83,6 +101,7 @@ pub struct Options {
mode: Mode,
sort_by: SortBy,
show_unknown: bool,
+ pub interactive: bool,
}
impl Args for Options {
@@ -96,12 +115,16 @@ impl Args for Options {
let mut reverse = None;
let mut field = None;
let mut show_unknown = false;
+ let mut interactive = false;
while let Some(arg) = parser.next()? {
match arg {
Long("help") | Short('h') => {
return Err(Error::Help.into());
}
+ Long("interactive") | Short('i') => {
+ interactive = true;
+ }
Long("all") | Short('a') if mode.is_none() => {
mode = Some(Mode::All);
}
@@ -168,6 +191,7 @@ impl Args for Options {
mode,
sort_by,
show_unknown,
+ interactive,
},
vec![],
))
@@ -178,14 +202,43 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let storage = &profile.storage;
let mut notifs = profile.notifications_mut()?;
+
let Options {
op,
mode,
sort_by,
show_unknown,
+ interactive,
} = options;
match op {
+ Operation::Default => {
+ if interactive {
+ if tui::is_installed() {
+ run_tui_operation(&profile, &storage, &mut notifs, sort_by, mode)?;
+ } else {
+ list(
+ mode,
+ sort_by,
+ show_unknown,
+ ¬ifs.read_only(),
+ storage,
+ &profile,
+ )?;
+ tui::installation_hint();
+ }
+ } else {
+ list(
+ mode,
+ sort_by,
+ show_unknown,
+ ¬ifs.read_only(),
+ storage,
+ &profile,
+ )?;
+ }
+ Ok(())
+ }
Operation::List => list(
mode,
sort_by,
@@ -574,3 +627,38 @@ fn show(
Ok(())
}
+
+/// Calls the inbox operation selection with `rad-tui inbox select`. An empty selection
+/// signals that the user did not select anything and exited the program. If the selection
+/// is not empty, the operation given will be called on the notification given.
+fn run_tui_operation(
+ profile: &Profile,
+ storage: &Storage,
+ notifs: &mut notifications::StoreWriter,
+ sort_by: SortBy,
+ mode: Mode,
+) -> anyhow::Result<()> {
+ let cmd = tui::Command::InboxSelectOperation {};
+
+ match tui::Selection::from_command(cmd) {
+ Ok(Some(selection)) => {
+ let operation = selection
+ .operation()
+ .ok_or_else(|| anyhow!("an operation must be provided"))?;
+
+ let notif_id: NotificationId = *selection
+ .ids()
+ .first()
+ .ok_or_else(|| anyhow!("a notification must be provided"))?;
+
+ match Operation::try_from(operation.as_str()) {
+ Ok(Operation::Show) => show(mode, notifs, storage, &profile),
+ Ok(Operation::Clear) => clear(mode, notifs),
+ Ok(_) => Err(anyhow!("operation not supported: {operation}")),
+ Err(err) => Err(err),
+ }
+ }
+ Ok(None) => Ok(()),
+ Err(err) => Err(err.into()),
+ }
+}
diff --git a/radicle-cli/src/tui.rs b/radicle-cli/src/tui.rs
index 9cbb743b1..87a7a06b5 100644
--- a/radicle-cli/src/tui.rs
+++ b/radicle-cli/src/tui.rs
@@ -4,6 +4,7 @@ use std::marker::PhantomData;
use std::str::FromStr;
use radicle::identity::Did;
+use radicle::node::notifications::NotificationId;
use serde::de;
use serde::{Deserialize, Deserializer};
@@ -130,6 +131,10 @@ pub enum Command {
},
/// Run patch id selection.
PatchSelectId,
+ /// Run notification id selection.
+ InboxSelectId,
+ /// Run patch operation and id selection with the given filter applied.
+ InboxSelectOperation {},
}
impl Command {
@@ -162,6 +167,18 @@ impl Command {
"id".into(),
]
.to_vec(),
+ Command::InboxSelectId => [
+ "inbox".into(),
+ "select".into(),
+ "--mode".into(),
+ "id".into(),
+ ]
+ .to_vec(),
+ Command::InboxSelectOperation {} => {
+ let mut args: Vec<OsString> = vec!["inbox".into(), "select".into()];
+
+ args
+ }
}
}
diff --git a/radicle-cli/src/tui/inbox.rs b/radicle-cli/src/tui/inbox.rs
new file mode 100644
index 000000000..af01a13e1
--- /dev/null
+++ b/radicle-cli/src/tui/inbox.rs
@@ -0,0 +1,23 @@
+use crate::commands::rad_inbox::SortBy;
+
+use super::*;
+use super::{SelectionOutput, TuiError};
+
+pub fn select_operation(
+ sort_by: SortBy,
+) -> Result<Option<SelectionOutput<NotificationId>>, TuiError> {
+ let mut args = vec!["inbox".to_string(), "select".to_string()];
+
+ args.push("--sort-by".to_string());
+ args.push(sort_by.field.to_string());
+
+ if sort_by.reverse {
+ args.push("--reverse".to_string());
+ }
+
+ match term::command::rad_tui(args) {
+ Ok(Some(output)) => Ok(Some(parse_output(&output)?)),
+ Ok(None) => Ok(None),
+ Err(err) => Err(TuiError::Command(err)),
+ }
+}
commit 7199932dc728402aa73bde092e86dc5b71aaaa88
Author: Erik Kundt <erik@zirkular.io>
Date: Wed Mar 13 22:14:41 2024 +0100
cli: Add optional patch selection TUI
diff --git a/radicle-cli/src/commands/patch.rs b/radicle-cli/src/commands/patch.rs
index 541d9b731..590b01d91 100644
--- a/radicle-cli/src/commands/patch.rs
+++ b/radicle-cli/src/commands/patch.rs
@@ -34,17 +34,20 @@ use std::ffi::OsString;
use anyhow::anyhow;
+use radicle::cob::patch;
use radicle::cob::patch::PatchId;
-use radicle::cob::{patch, Label};
+use radicle::cob::{Label, ObjectId};
use radicle::patch::cache::Patches as _;
-use radicle::storage::git::transport;
+use radicle::storage::git::{transport, Repository};
use radicle::{prelude::*, Node};
+use radicle_term::command::CommandError;
-use crate::git::Rev;
+use crate::git::{self, Rev};
use crate::node;
use crate::terminal as term;
use crate::terminal::args::{string, Args, Error, Help};
use crate::terminal::patch::Message;
+use crate::tui;
pub const HELP: Help = Help {
name: "patch",
@@ -152,6 +155,7 @@ Other options
--repo <rid> Operate on the given repository (default: cwd)
--[no-]announce Announce changes made to the network
+ -i --interactive Allow usage of `rad-tui` to override the operation's default behaviour (default: false)
-q, --quiet Quiet output
--help Print help
"#,
@@ -159,6 +163,8 @@ Other options
#[derive(Debug, Default, PartialEq, Eq)]
pub enum OperationName {
+ #[default]
+ Default,
Assign,
Show,
Diff,
@@ -170,7 +176,6 @@ pub enum OperationName {
Ready,
Review,
Label,
- #[default]
List,
Edit,
Redact,
@@ -178,6 +183,60 @@ pub enum OperationName {
Cache,
}
+impl TryFrom<&str> for OperationName {
+ type Error = anyhow::Error;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ match value {
+ "default" => Ok(OperationName::Default),
+ "assign" => Ok(OperationName::Assign),
+ "show" => Ok(OperationName::Show),
+ "diff" => Ok(OperationName::Diff),
+ "update" => Ok(OperationName::Update),
+ "archive" => Ok(OperationName::Archive),
+ "delete" => Ok(OperationName::Delete),
+ "checkout" => Ok(OperationName::Checkout),
+ "comment" => Ok(OperationName::Comment),
+ "ready" => Ok(OperationName::Ready),
+ "review" => Ok(OperationName::Review),
+ "label" => Ok(OperationName::Label),
+ "list" => Ok(OperationName::List),
+ "edit" => Ok(OperationName::Edit),
+ "redact" => Ok(OperationName::Redact),
+ "set" => Ok(OperationName::Set),
+ "cache" => Ok(OperationName::Cache),
+ _ => Err(anyhow!("invalid operation name: {value}")),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct ListOptions {
+ status: Option<patch::Status>,
+ authored: bool,
+ authors: Vec<Did>,
+}
+
+impl Default for ListOptions {
+ fn default() -> Self {
+ Self {
+ status: Some(patch::Status::Open),
+ authored: false,
+ authors: vec![],
+ }
+ }
+}
+
+impl ListOptions {
+ pub fn collect_authors(&self, profile: &Profile) -> BTreeSet<Did> {
+ let mut authors: BTreeSet<Did> = self.authors.iter().cloned().collect();
+ if self.authored {
+ authors.insert(profile.did());
+ }
+ authors
+ }
+}
+
#[derive(Debug, Default, PartialEq, Eq)]
pub struct AssignOptions {
pub add: BTreeSet<Did>,
@@ -192,67 +251,70 @@ pub struct LabelOptions {
#[derive(Debug)]
pub enum Operation {
+ Default {
+ opts: ListOptions,
+ },
Show {
- patch_id: Rev,
+ patch_id: Option<Rev>,
diff: bool,
debug: bool,
},
Diff {
- patch_id: Rev,
+ patch_id: Option<Rev>,
revision_id: Option<Rev>,
},
Update {
- patch_id: Rev,
+ patch_id: Option<Rev>,
base_id: Option<Rev>,
message: Message,
},
Archive {
- patch_id: Rev,
+ patch_id: Option<Rev>,
undo: bool,
},
Ready {
- patch_id: Rev,
+ patch_id: Option<Rev>,
undo: bool,
},
Delete {
- patch_id: Rev,
+ patch_id: Option<Rev>,
},
Checkout {
- patch_id: Rev,
+ patch_id: Option<Rev>,
revision_id: Option<Rev>,
opts: checkout::Options,
},
Comment {
- revision_id: Rev,
+ revision_id: Option<Rev>,
message: Message,
reply_to: Option<Rev>,
},
Review {
- patch_id: Rev,
+ patch_id: Option<Rev>,
revision_id: Option<Rev>,
opts: review::Options,
},
Assign {
- patch_id: Rev,
+ patch_id: Option<Rev>,
opts: AssignOptions,
},
Label {
- patch_id: Rev,
+ patch_id: Option<Rev>,
opts: LabelOptions,
},
List {
- filter: Option<patch::Status>,
+ opts: ListOptions,
},
Edit {
- patch_id: Rev,
+ patch_id: Option<Rev>,
revision_id: Option<Rev>,
message: Message,
},
Redact {
- revision_id: Rev,
+ revision_id: Option<Rev>,
},
Set {
- patch_id: Rev,
+ patch_id: Option<Rev>,
},
Cache {
patch_id: Option<Rev>,
@@ -278,6 +340,7 @@ impl Operation {
| Operation::Checkout { .. }
| Operation::List { .. }
| Operation::Cache { .. } => false,
+ Operation::Default { .. } => false,
}
}
}
@@ -287,10 +350,9 @@ pub struct Options {
pub op: Operation,
pub repo: Option<RepoId>,
pub announce: bool,
+ pub interactive: bool,
pub verbose: bool,
pub quiet: bool,
- pub authored: bool,
- pub authors: Vec<Did>,
}
impl Args for Options {
@@ -301,17 +363,16 @@ impl Args for Options {
let mut op: Option<OperationName> = None;
let mut verbose = false;
let mut quiet = false;
- let mut authored = false;
- let mut authors = vec![];
let mut announce = true;
+ let mut interactive = false;
let mut patch_id = None;
let mut revision_id = None;
let mut message = Message::default();
- let mut filter = Some(patch::Status::Open);
let mut diff = false;
let mut debug = false;
let mut undo = false;
let mut reply_to: Option<Rev> = None;
+ let mut list_opts = ListOptions::default();
let mut checkout_opts = checkout::Options::default();
let mut assign_opts = AssignOptions::default();
let mut label_opts = LabelOptions::default();
@@ -489,28 +550,32 @@ impl Args for Options {
// List options.
Long("all") => {
- filter = None;
+ list_opts.status = None;
}
Long("draft") => {
- filter = Some(patch::Status::Draft);
+ list_opts.status = Some(patch::Status::Draft);
}
- Long("archived") => {
- filter = Some(patch::Status::Archived);
+ Long("open") => {
+ list_opts.status = Some(patch::Status::Open);
}
Long("merged") => {
- filter = Some(patch::Status::Merged);
+ list_opts.status = Some(patch::Status::Merged);
}
- Long("open") => {
- filter = Some(patch::Status::Open);
+ Long("archived") => {
+ list_opts.status = Some(patch::Status::Archived);
}
Long("authored") => {
- authored = true;
+ list_opts.authored = true;
}
- Long("author") if op == Some(OperationName::List) => {
- authors.push(term::args::did(&parser.value()?)?);
+ Long("author") => {
+ let val = parser.value()?;
+ list_opts.authors.push(term::args::did(&val)?);
}
// Common.
+ Long("interactive") | Short('i') => {
+ interactive = true;
+ }
Long("verbose") | Short('v') => {
verbose = true;
}
@@ -581,71 +646,58 @@ impl Args for Options {
}
let op = match op.unwrap_or_default() {
- OperationName::List => Operation::List { filter },
+ OperationName::Default => Operation::Default { opts: list_opts },
+ OperationName::List => Operation::List { opts: list_opts },
OperationName::Show => Operation::Show {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
+ patch_id,
diff,
debug,
},
OperationName::Diff => Operation::Diff {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
+ patch_id,
revision_id,
},
- OperationName::Delete => Operation::Delete {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
- },
+ OperationName::Delete => Operation::Delete { patch_id },
OperationName::Update => Operation::Update {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
+ patch_id,
base_id,
message,
},
- OperationName::Archive => Operation::Archive {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
- undo,
- },
+ OperationName::Archive => Operation::Archive { patch_id, undo },
OperationName::Checkout => Operation::Checkout {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
+ patch_id,
revision_id,
opts: checkout_opts,
},
OperationName::Comment => Operation::Comment {
- revision_id: patch_id
- .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
+ revision_id: patch_id,
message,
reply_to,
},
OperationName::Review => Operation::Review {
- patch_id: patch_id
- .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
+ patch_id,
revision_id,
opts: review::Options {
message,
op: review_op,
},
},
- OperationName::Ready => Operation::Ready {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
- undo,
- },
+ OperationName::Ready => Operation::Ready { patch_id, undo },
OperationName::Edit => Operation::Edit {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
+ patch_id,
revision_id,
message,
},
- OperationName::Redact => Operation::Redact {
- revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?,
- },
+ OperationName::Redact => Operation::Redact { revision_id },
OperationName::Assign => Operation::Assign {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
+ patch_id,
opts: assign_opts,
},
OperationName::Label => Operation::Label {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
+ patch_id,
opts: label_opts,
},
- OperationName::Set => Operation::Set {
- patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
- },
+ OperationName::Set => Operation::Set { patch_id },
OperationName::Cache => Operation::Cache { patch_id },
};
@@ -654,10 +706,9 @@ impl Args for Options {
op,
repo,
verbose,
+ interactive,
quiet,
announce,
- authored,
- authors,
},
vec![],
))
@@ -680,19 +731,42 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
transport::local::register(profile.storage.clone());
match options.op {
- Operation::List { filter } => {
- let mut authors: BTreeSet<Did> = options.authors.iter().cloned().collect();
- if options.authored {
- authors.insert(profile.did());
+ Operation::Default { opts } => {
+ if options.interactive {
+ if tui::is_installed() {
+ run_tui_operation(opts, &profile, &repository, workdir.as_ref())?;
+ } else {
+ list::run(
+ opts.status.as_ref(),
+ opts.collect_authors(&profile),
+ &repository,
+ &profile,
+ )?;
+ tui::installation_hint();
+ }
+ } else {
+ list::run(
+ opts.status.as_ref(),
+ opts.collect_authors(&profile),
+ &repository,
+ &profile,
+ )?;
}
- list::run(filter.as_ref(), authors, &repository, &profile)?;
+ }
+ Operation::List { opts } => {
+ list::run(
+ opts.status.as_ref(),
+ opts.collect_authors(&profile),
+ &repository,
+ &profile,
+ )?;
}
Operation::Show {
patch_id,
diff,
debug,
} => {
- let patch_id = patch_id.resolve(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
show::run(
&patch_id,
diff,
@@ -707,7 +781,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
patch_id,
revision_id,
} => {
- let patch_id = patch_id.resolve(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
let revision_id = revision_id
.map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
.transpose()?
@@ -715,11 +789,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
diff::run(&patch_id, revision_id, &repository, &profile)?;
}
Operation::Update {
- ref patch_id,
+ patch_id,
ref base_id,
ref message,
} => {
- let patch_id = patch_id.resolve(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
let base_id = base_id
.as_ref()
.map(|base| base.resolve(&repository.backend))
@@ -738,11 +812,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
)?;
}
Operation::Archive { ref patch_id, undo } => {
- let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id.clone())?;
archive::run(&patch_id, undo, &profile, &repository)?;
}
Operation::Ready { ref patch_id, undo } => {
- let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id.clone())?;
if !ready::run(&patch_id, undo, &profile, &repository)? {
if undo {
@@ -753,7 +827,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
}
}
Operation::Delete { patch_id } => {
- let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
delete::run(&patch_id, &profile, &repository)?;
}
Operation::Checkout {
@@ -761,7 +835,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
revision_id,
opts,
} => {
- let patch_id = patch_id.resolve::<radicle::git::Oid>(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
let revision_id = revision_id
.map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
.transpose()?
@@ -783,6 +857,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
message,
reply_to,
} => {
+ let revision_id = revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?;
comment::run(
revision_id,
message,
@@ -797,7 +872,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
revision_id,
opts,
} => {
- let patch_id = patch_id.resolve(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
let revision_id = revision_id
.map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
.transpose()?
@@ -809,7 +884,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
revision_id,
message,
} => {
- let patch_id = patch_id.resolve(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
let revision_id = revision_id
.map(|id| id.resolve::<radicle::git::Oid>(&repository.backend))
.transpose()?
@@ -817,25 +892,26 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
edit::run(&patch_id, revision_id, message, &profile, &repository)?;
}
Operation::Redact { revision_id } => {
+ let revision_id = revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?;
redact::run(&revision_id, &profile, &repository)?;
}
Operation::Assign {
patch_id,
opts: AssignOptions { add, delete },
} => {
- let patch_id = patch_id.resolve(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
assign::run(&patch_id, add, delete, &profile, &repository)?;
}
Operation::Label {
patch_id,
opts: LabelOptions { add, delete },
} => {
- let patch_id = patch_id.resolve(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
label::run(&patch_id, add, delete, &profile, &repository)?;
}
Operation::Set { patch_id } => {
let patches = profile.patches(&repository)?;
- let patch_id = patch_id.resolve(&repository.backend)?;
+ let patch_id = resolve_patch_id(&repository, patch_id)?;
let patch = patches
.get(&patch_id)?
.ok_or_else(|| anyhow!("patch {patch_id} not found"))?;
@@ -864,3 +940,79 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
}
Ok(())
}
+
+fn resolve_patch_id(repository: &Repository, rev: Option<Rev>) -> anyhow::Result<ObjectId> {
+ if let Some(rev) = rev {
+ Ok(rev.resolve(&repository.backend)?)
+ } else {
+ match tui::Selection::from_command(tui::Command::PatchSelectId) {
+ Ok(selection) => {
+ let patch_id = selection.and_then(|s| s.ids().first().cloned());
+ patch_id.ok_or_else(|| anyhow!("a patch must be provided"))
+ }
+ Err(tui::Error::Command(CommandError::NotFound)) => {
+ tui::installation_hint();
+ Err(anyhow!("a patch must be provided"))
+ }
+ Err(err) => Err(err.into()),
+ }
+ }
+}
+
+/// Calls the patch operation selection with `rad-tui patch select`. An empty selection
+/// signals that the user did not select anything and exited the program. If the selection
+/// is not empty, the operation given will be called on the patch given.
+fn run_tui_operation(
+ options: ListOptions,
+ profile: &Profile,
+ repository: &Repository,
+ workdir: Option<&git::Repository>,
+) -> anyhow::Result<()> {
+ let cmd = tui::Command::PatchSelectOperation {
+ status: options
+ .status
+ .map_or("all".to_string(), |status| status.to_string()),
+ authored: options.authored,
+ authors: options.authors.clone(),
+ };
+
+ match tui::Selection::from_command(cmd) {
+ Ok(Some(selection)) => {
+ let operation = selection
+ .operation()
+ .ok_or_else(|| anyhow!("an operation must be provided"))?;
+ let patch_id = selection
+ .ids()
+ .first()
+ .ok_or_else(|| anyhow!("a patch must be provided"))?;
+
+ match OperationName::try_from(operation.as_str()) {
+ Ok(OperationName::Show) => {
+ show::run(patch_id, false, false, false, profile, repository, workdir)
+ }
+ Ok(OperationName::Checkout) => {
+ let revision_id = None;
+ let workdir = workdir.ok_or(anyhow!(
+ "this command must be run from a repository checkout"
+ ))?;
+ checkout::run(
+ patch_id,
+ revision_id,
+ repository,
+ workdir,
+ profile,
+ checkout::Options::default(),
+ )
+ }
+ Ok(OperationName::Diff) => {
+ let revision_id = None;
+ diff::run(patch_id, revision_id, repository, profile)
+ }
+ Ok(_) => Err(anyhow!("operation not supported: {operation}")),
+ Err(err) => Err(err),
+ }
+ }
+ Ok(None) => Ok(()),
+ Err(err) => Err(err.into()),
+ }
+}
commit 98ea75940e110ad5bfe45c48f904848622d485a0
Author: Erik Kundt <erik@zirkular.io>
Date: Wed Mar 13 22:14:19 2024 +0100
cli: Add TUI module w/ patch id and operation selection
diff --git a/radicle-cli/src/lib.rs b/radicle-cli/src/lib.rs
index 32019151d..4c3083d87 100644
--- a/radicle-cli/src/lib.rs
+++ b/radicle-cli/src/lib.rs
@@ -7,3 +7,4 @@ pub mod node;
pub mod pager;
pub mod project;
pub mod terminal;
+pub mod tui;
diff --git a/radicle-cli/src/tui.rs b/radicle-cli/src/tui.rs
new file mode 100644
index 000000000..9cbb743b1
--- /dev/null
+++ b/radicle-cli/src/tui.rs
@@ -0,0 +1,279 @@
+use std::ffi::OsString;
+use std::fmt;
+use std::marker::PhantomData;
+use std::str::FromStr;
+
+use radicle::identity::Did;
+
+use serde::de;
+use serde::{Deserialize, Deserializer};
+
+use crate::terminal as term;
+use term::command::CommandError;
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ #[error("error running TUI command: {0}")]
+ Command(#[from] CommandError),
+ #[error("error parsing TUI output: {0}")]
+ Parser(#[from] serde_json::Error),
+}
+
+/// A `Selection` is expected to be constructed from output of calls
+/// to TUI subprpcesses via JSON deserialization. For example, running
+/// `rad patch` spawns a subprocess with `rad-tui patch select` and expects
+/// a JSON output that can be deserialized into a `Selection`.
+/// Note that the `Id` parameter must implement `FromStr` so that it can
+/// be parsed during this deserialization.
+#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Selection<Id>
+where
+ Id: FromStr,
+ Id::Err: fmt::Display,
+{
+ /// The selected operation.
+ operation: Option<String>,
+ /// The selected id(s).
+ #[serde(deserialize_with = "Selection::deserialize_ids")]
+ ids: Vec<Id>,
+ // Optional CLI args.
+ args: Option<Vec<String>>,
+}
+
+impl<Id> Selection<Id>
+where
+ Id::Err: fmt::Display,
+ Id: FromStr,
+{
+ pub fn operation(&self) -> Option<&String> {
+ self.operation.as_ref()
+ }
+
+ pub fn ids(&self) -> &Vec<Id> {
+ &self.ids
+ }
+
+ pub fn args(&self) -> Option<&Vec<String>> {
+ self.args.as_ref()
+ }
+
+ pub fn from_command(command: Command) -> Result<Option<Self>, Error> {
+ match command.run() {
+ Ok(Some(output)) => Ok(parse(&output)?),
+ Ok(None) => Ok(None),
+ Err(err) => Err(err),
+ }
+ }
+
+ fn deserialize_ids<'de, D>(deserializer: D) -> Result<Vec<Id>, D::Error>
+ where
+ D: Deserializer<'de>,
+ Id: FromStr,
+ Id::Err: fmt::Display,
+ {
+ struct IdsVisitor<Id>(PhantomData<Id>);
+
+ impl<'de, Id> de::Visitor<'de> for IdsVisitor<Id>
+ where
+ Id: FromStr,
+ Id::Err: fmt::Display,
+ {
+ type Value = Vec<Id>;
+
+ fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+ formatter.write_str("a list of selectable identifiers")
+ }
+
+ fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+ where
+ A: de::SeqAccess<'de>,
+ {
+ use serde::de::Error;
+
+ let mut ids = seq
+ .size_hint()
+ .map_or_else(|| Vec::new(), |n| Vec::with_capacity(n));
+ while let Some(id) = seq.next_element::<String>()? {
+ ids.push(id.parse().map_err(A::Error::custom)?);
+ }
+ Ok(ids)
+ }
+ }
+
+ deserializer.deserialize_seq(IdsVisitor(PhantomData))
+ }
+}
+
+impl<Id> Default for Selection<Id>
+where
+ Id::Err: fmt::Display,
+ Id: FromStr,
+{
+ fn default() -> Self {
+ Self {
+ operation: None,
+ ids: vec![],
+ args: None,
+ }
+ }
+}
+
+/// A `Command` defines a set of arguments and executes `rad-tui` with these
+/// when `run()` is called.
+pub enum Command {
+ /// Run patch operation and id selection with the given filter applied.
+ PatchSelectOperation {
+ status: String,
+ authored: bool,
+ authors: Vec<Did>,
+ },
+ /// Run patch id selection.
+ PatchSelectId,
+}
+
+impl Command {
+ /// Returns the required and potentially mapped arguments for a call to `rad-tui`
+ fn args(&self) -> Vec<OsString> {
+ match self {
+ Command::PatchSelectOperation {
+ status,
+ authored,
+ authors,
+ } => {
+ let mut args: Vec<OsString> = vec!["patch".into(), "select".into()];
+
+ args.push(format!("--{}", status).into());
+
+ if *authored {
+ args.push("--authored".into());
+ }
+ for author in authors {
+ args.push("--author".into());
+ args.push(format!("{author}").into());
+ }
+
+ args
+ }
+ Command::PatchSelectId => [
+ "patch".into(),
+ "select".into(),
+ "--mode".into(),
+ "id".into(),
+ ]
+ .to_vec(),
+ }
+ }
+
+ /// Runs `rad-tui` with this `Command`'s arguments.
+ fn run(&self) -> Result<Option<String>, Error> {
+ term::command::rad_tui(self.args()).map_err(Error::Command)
+ }
+}
+
+/// Deserializes the output of `rad-tui` and constructs the desired type.
+fn parse<'a, T: Deserialize<'a>>(output: &'a str) -> Result<T, Error> {
+ match serde_json::from_str::<'a, T>(output) {
+ Ok(output) => Ok(output),
+ Err(err) => Err(Error::Parser(err)),
+ }
+}
+
+pub fn is_installed() -> bool {
+ use std::io::ErrorKind;
+ use std::process::{Command, Stdio};
+
+ let not_found = Command::new(term::command::RAD_TUI)
+ .stdout(Stdio::null())
+ .spawn()
+ .is_err_and(|err| err.kind() == ErrorKind::NotFound);
+
+ !not_found
+}
+
+pub fn installation_hint() {
+ term::hint("An experimental TUI can be enabled by installing `rad-tui`. You can download it from https://files.radicle.xyz/.");
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use radicle::cob::ObjectId;
+
+ #[test]
+ fn parse_selection_output_succeeds() -> Result<(), Error> {
+ let id = ObjectId::from_str("e65863e71192c107282fbc3170b1ad11b6593b37")
+ .expect("Cannot parse object id");
+
+ let json =
+ r#"{"operation":"show","ids":["e65863e71192c107282fbc3170b1ad11b6593b37"],"args":[]}"#;
+ let selection = parse(json)?;
+
+ assert_eq!(
+ Selection {
+ ids: vec![id],
+ operation: Some("show".to_string()),
+ args: Some(vec![]),
+ },
+ selection
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn parse_selection_output_succeeds_with_missing_operation() -> Result<(), Error> {
+ let id = ObjectId::from_str("e65863e71192c107282fbc3170b1ad11b6593b37")
+ .expect("Cannot parse object id");
+
+ let json = r#"{"ids":["e65863e71192c107282fbc3170b1ad11b6593b37"],"args":[]}"#;
+ let selection = parse(json)?;
+
+ assert_eq!(
+ Selection {
+ ids: vec![id],
+ operation: None,
+ args: Some(vec![]),
+ },
+ selection
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn parse_selection_output_succeeds_with_missing_args() -> Result<(), Error> {
+ let id = ObjectId::from_str("e65863e71192c107282fbc3170b1ad11b6593b37")
+ .expect("Cannot parse object id");
+
+ let json = r#"{"ids":["e65863e71192c107282fbc3170b1ad11b6593b37"]}"#;
+ let selection = parse(json)?;
+
+ assert_eq!(
+ Selection {
+ ids: vec![id],
+ operation: None,
+ args: None,
+ },
+ selection
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn parse_selection_output_fails_with_missing_ids() -> Result<(), Error> {
+ let json = r#"{"operation":null,"args":[]}"#;
+ let selection: Result<Selection<ObjectId>, Error> = parse(json);
+
+ assert!(selection.is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn parse_selection_output_fails_with_invalid_ids() -> Result<(), Error> {
+ let json = r#"{"ids":["radicle"]}"#;
+ let selection: Result<Selection<ObjectId>, Error> = parse(json);
+
+ assert!(selection.is_err());
+ Ok(())
+ }
+}
commit a15267fe2eb426aff51fba0c2ea62f2c6cf75159
Author: Erik Kundt <erik@zirkular.io>
Date: Wed Mar 13 22:13:48 2024 +0100
term: Add support for TUI commands
diff --git a/Cargo.lock b/Cargo.lock
index 5c925f61e..3e0ac0c57 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2597,6 +2597,7 @@ dependencies = [
"pretty_assertions",
"tempfile",
"termion 3.0.0",
+ "thiserror",
"unicode-display-width",
"unicode-segmentation",
"zeroize",
diff --git a/radicle-term/Cargo.toml b/radicle-term/Cargo.toml
index 5be3bc6d8..87aaa8792 100644
--- a/radicle-term/Cargo.toml
+++ b/radicle-term/Cargo.toml
@@ -18,6 +18,7 @@ inquire = { version = "0.6.2", default-features = false, features = ["termion",
libc = { version = "0.2" }
once_cell = { version = "1.13" }
termion = { version = "3" }
+thiserror = { version = "1.0.56" }
unicode-display-width = { version = "0.3.0" }
unicode-segmentation = { version = "1.7.1" }
zeroize = { version = "1.1" }
diff --git a/radicle-term/src/command.rs b/radicle-term/src/command.rs
index 83844ea81..74322a4cb 100644
--- a/radicle-term/src/command.rs
+++ b/radicle-term/src/command.rs
@@ -1,5 +1,20 @@
-use std::io::Write;
+use std::io::{self, Write};
use std::process::{Command, Stdio};
+use std::string::FromUtf8Error;
+
+pub const RAD_TUI: &str = "rad-tui";
+
+#[derive(thiserror::Error, Debug)]
+pub enum CommandError {
+ #[error("command error: not found")]
+ NotFound,
+ #[error("command error: an internal error occured.")]
+ Internal,
+ #[error("command error: converting output failed: {0}")]
+ Output(#[from] FromUtf8Error),
+ #[error("command error: retrieving output failed: {0}")]
+ Other(#[from] io::Error),
+}
pub fn bat<S: AsRef<std::ffi::OsStr>>(
args: impl IntoIterator<Item = S>,
@@ -17,3 +32,26 @@ pub fn bat<S: AsRef<std::ffi::OsStr>>(
Ok(())
}
+
+pub fn rad_tui<S: AsRef<std::ffi::OsStr>>(
+ args: impl IntoIterator<Item = S>,
+) -> anyhow::Result<Option<String>, CommandError> {
+ match Command::new(RAD_TUI)
+ .stderr(Stdio::piped())
+ .args(args)
+ .spawn()
+ {
+ Ok(child) => {
+ let output = child.wait_with_output()?;
+ let stderr = String::from_utf8(output.stderr)?.trim().to_owned();
+
+ if !output.status.success() {
+ return Err(CommandError::Internal);
+ }
+
+ Ok((!stderr.is_empty()).then_some(stderr))
+ }
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Err(CommandError::NotFound),
+ Err(err) => Err(CommandError::Other(err)),
+ }
+}
Exit code: 0
shell: 'cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny clippy::all cargo build --all-targets --workspace cargo doc --workspace cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 0ac8bf67-3de8-4267-b88e-47414ecbcaac -v /opt/radcis/ci.rad.levitte.org/cci/state/0ac8bf67-3de8-4267-b88e-47414ecbcaac/s:/0ac8bf67-3de8-4267-b88e-47414ecbcaac/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/0ac8bf67-3de8-4267-b88e-47414ecbcaac/w:/0ac8bf67-3de8-4267-b88e-47414ecbcaac/w -w /0ac8bf67-3de8-4267-b88e-47414ecbcaac/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /0ac8bf67-3de8-4267-b88e-47414ecbcaac/s/script.sh
+ cargo --version
info: syncing channel updates for '1.77-x86_64-unknown-linux-gnu'
info: latest update on 2024-04-09, rust version 1.77.2 (25ef9e3d8 2024-04-09)
info: downloading component 'cargo'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: installing component 'cargo'
info: installing component 'rust-std'
info: installing component 'rustc'
cargo 1.77.2 (e52e36006 2024-03-26)
+ rustc --version
rustc 1.77.2 (25ef9e3d8 2024-04-09)
+ cargo fmt --check
error: 'cargo-fmt' is not installed for the toolchain '1.77-x86_64-unknown-linux-gnu'.
To install, run `rustup component add --toolchain 1.77-x86_64-unknown-linux-gnu rustfmt`
Exit code: 1
{
"response": "finished",
"result": "failure"
}