Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli: Add optional notification selector call via `rad-tui`
Archived did:key:z6MkgFq6...nBGz opened 2 years ago
8 files changed +687 -87 ea69168f 551132a5
modified Cargo.lock
@@ -2597,6 +2597,7 @@ dependencies = [
 "pretty_assertions",
 "tempfile",
 "termion 3.0.0",
+
 "thiserror",
 "unicode-display-width",
 "unicode-segmentation",
 "zeroize",
modified 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,
+
                        &notifs.read_only(),
+
                        storage,
+
                        &profile,
+
                    )?;
+
                    tui::installation_hint();
+
                }
+
            } else {
+
                list(
+
                    mode,
+
                    sort_by,
+
                    show_unknown,
+
                    &notifs.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()),
+
    }
+
}
modified 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()),
+
    }
+
}
modified radicle-cli/src/lib.rs
@@ -7,3 +7,4 @@ pub mod node;
pub mod pager;
pub mod project;
pub mod terminal;
+
pub mod tui;
added radicle-cli/src/tui.rs
@@ -0,0 +1,296 @@
+
use std::ffi::OsString;
+
use std::fmt;
+
use std::marker::PhantomData;
+
use std::str::FromStr;
+

+
use radicle::identity::Did;
+
use radicle::node::notifications::NotificationId;
+

+
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,
+
    /// Run notification id selection.
+
    InboxSelectId,
+
    /// Run patch operation and id selection with the given filter applied.
+
    InboxSelectOperation {},
+
}
+

+
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(),
+
            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
+
            }
+
        }
+
    }
+

+
    /// 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(())
+
    }
+
}
added 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)),
+
    }
+
}
modified 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" }
modified 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)),
+
    }
+
}