Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
CLI integration
Merged did:key:z6MkswQE...2C1V opened 1 year ago

This reworks the binary to work as drop-in replacement for rad. With these changes, the binary will handle all known commands and operations, and will forward all unknown ones to rad.

25 files changed +4373 -3409 976346c4 ed80fcee
modified Cargo.lock
@@ -212,6 +212,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"

[[package]]
+
name = "assert_cmd"
+
version = "2.0.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d"
+
dependencies = [
+
 "anstyle",
+
 "bstr",
+
 "doc-comment",
+
 "libc",
+
 "predicates",
+
 "predicates-core",
+
 "predicates-tree",
+
 "wait-timeout",
+
]
+

+
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -314,6 +330,17 @@ dependencies = [
]

[[package]]
+
name = "bstr"
+
version = "1.11.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
+
dependencies = [
+
 "memchr",
+
 "regex-automata",
+
 "serde",
+
]
+

+
[[package]]
name = "bumpalo"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -681,6 +708,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"

[[package]]
+
name = "difflib"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+

+
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -693,6 +726,12 @@ dependencies = [
]

[[package]]
+
name = "doc-comment"
+
version = "0.3.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
+

+
[[package]]
name = "dyn-clone"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -823,6 +862,15 @@ dependencies = [
]

[[package]]
+
name = "float-cmp"
+
version = "0.10.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
+
dependencies = [
+
 "num-traits",
+
]
+

+
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1643,6 +1691,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"

[[package]]
+
name = "predicates"
+
version = "3.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
+
dependencies = [
+
 "anstyle",
+
 "difflib",
+
 "float-cmp",
+
 "normalize-line-endings",
+
 "predicates-core",
+
 "regex",
+
]
+

+
[[package]]
+
name = "predicates-core"
+
version = "1.0.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
+

+
[[package]]
+
name = "predicates-tree"
+
version = "1.0.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
+
dependencies = [
+
 "predicates-core",
+
 "termtree",
+
]
+

+
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1940,6 +2018,7 @@ version = "0.5.1"
dependencies = [
 "ansi-to-tui",
 "anyhow",
+
 "assert_cmd",
 "fuzzy-matcher",
 "git2",
 "homedir",
@@ -1949,6 +2028,7 @@ dependencies = [
 "libc",
 "log",
 "nom",
+
 "predicates",
 "pretty_assertions",
 "radicle",
 "radicle-cli",
@@ -2603,6 +2683,12 @@ dependencies = [
]

[[package]]
+
name = "termtree"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+

+
[[package]]
name = "textwrap"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3036,6 +3122,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"

[[package]]
+
name = "wait-timeout"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
name = "wasi"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -53,6 +53,8 @@ tui-textarea = { version = "0.7.0", default-features = false, features = ["termi
tui-tree-widget = { version = "0.23.0" }

[dev-dependencies]
+
assert_cmd = "2.0.14"
+
predicates = "3.1.0"
pretty_assertions = "^1.4.1"
radicle = { version = "0.14.0", features = ["test"]}
radicle-git-ext = { version = "0.8.0", features = ["serde"] }
modified README.md
@@ -19,17 +19,32 @@

## Getting started

-
This crate provides a binary called `rad-tui` which contains all user interfaces. Specific interfaces can be run by the appropriate command, e.g. `rad-tui patch select` shows a patch selector.
+
This crate provides a binary called `rad-tui` which can be used as a drop-in replacement for `rad`. It maps known commands and operations to internal ones, running the corresponding interface, e.g.

-
The interfaces are designed to be modular and to integrate well with the existing Radicle CLI. Right now, the binary is meant to be called from `rad`, which will collect and process its output, e.g.
+
```
+
rad-tui patch
+
```
+

+
runs the patch list interface and calls `rad` with the operation and id selected. Commands or operations not known to `rad-tui` will be forwarded to `rad`, e.g. the following just calls `rad node`:
+

+
```
+
rad-tui node
+
```
+

+
The default forwarding behaviour can be overridden with a flag, e.g.

```
-
rad patch show
+
rad-tui help --no-forward
```
+
runs the internal help command instead of forwarding to `rad help`.

-
will show a patch selector and pass on the id of the selected patch.
+
### Using a shell alias

-
> **Note:** The integration into the Radicle CLI is not fully done, yet. Please refer to the [Usage](#usage) section for information on how to use `rad-tui` already.
+
In order to make the CLI integration opaque, a shell alias can be used:
+

+
```
+
alias rad="rad-tui"
+
```

### Installation

@@ -99,53 +114,24 @@ home.packages = [

### Usage

-
Soon, `rad-tui` will be integrated into [`heartwood`](https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5). Until then, you can use the `rad` proxy script that is provided. It's considered to be a drop-in replacement for `rad` and can be used for testing and prototyping purposes. It should reflect the current behavior, as if `rad-tui` would have been integrated, e.g.
-

-
```sh
-
# show an interface that let's you select a patch
-
./rad.sh patch show
-
```
-

-
```sh
-
# show an interface that let's you select a patch and an operation
-
./rad.sh patch --tui
-
```
-

-
Both commands will call into `rad-tui`, process its output and call `rad` accordingly.
-

-
#### Interfaces
-

-
##### Selection
+
#### List

Select a patch, an issue or a notification and an operation:

```
-
rad-tui <patch | issue | inbox> select
-
```
-

-
Same as above:
-

-
```
-
rad-tui <patch | issue | inbox> select --mode operation
+
rad-tui <patch | issue | inbox>
+
rad-tui <patch | issue | inbox> list
```

-
Select a patch, an issue or a notification only and return its id:
-

-
```
-
rad-tui <patch | issue | inbox> select --mode id
-
```
+
#### CLI integration via JSON

-
##### Patch
+
The interfaces are designed to be modular and could also be integrated with existing CLI tooling. The binary is can be called and its output collected and processed, e.g.

-
Review a patch revision:
```
-
rad-tui patch review <id>
+
rad-tui patch list --json
```
-
> **Note:** When the review is done, it needs to be finalized via `rad patch review [--accept | --reject] <id>`.
-

-
#### Output

-
All interfaces return a common JSON object on `stderr` that reflects the choices made by the user, e.g.:
+
runs the patch list interface and return a JSON object specifying the operation and id selected:

```
{ "operation": "show", "ids": ["546443226b300484a97a2b2d7c7000af6e8169ba"], args:[] }
modified bin/commands/help.rs
@@ -1,15 +1,16 @@
use std::ffi::OsString;

+
use radicle_cli::terminal as cli_term;
use radicle_term as term;

-
use crate::terminal::args::{Args, Error, Help};
-
use crate::terminal::Context;
+
use cli_term::args::{Args, Error, Help};
+
use cli_term::Context;

use super::*;

pub const HELP: Help = Help {
    name: "help",
-
    description: "TUI help",
+
    description: "Print help",
    version: env!("CARGO_PKG_VERSION"),
    usage: "Usage: rad-tui help [--help]",
};
@@ -20,18 +21,17 @@ const COMMANDS: &[Help] = &[tui_help::HELP];
pub struct Options {}

impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        let mut parser = lexopt::Parser::from_args(args);
-

-
        if let Some(arg) = parser.next()? {
-
            return Err(anyhow::anyhow!(arg.unexpected()));
-
        }
+
    fn from_args(_args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
        Err(Error::HelpManual { name: "rad-tui" }.into())
    }
}

pub fn run(_options: Options, ctx: impl Context) -> anyhow::Result<()> {
-
    term::print("Usage: rad-tui <command> [--help]");
+
    println!(
+
        "{} {}",
+
        term::format::secondary("Usage:").bold(),
+
        term::format::tertiary("rad-tui [COMMAND] [OPTIONS]"),
+
    );

    if let Err(e) = ctx.profile() {
        term::blank();
@@ -50,18 +50,50 @@ pub fn run(_options: Options, ctx: impl Context) -> anyhow::Result<()> {
        term::blank();
    }

-
    term::print("Common `rad-tui` commands used in various situations:");
    term::blank();
+
    println!("{}", term::format::secondary("Options:").bold(),);
+
    term::info!(
+
        "\t{} {}",
+
        term::format::tertiary(format!("{:-16}", "--no-forward")),
+
        term::format::default("Don't forward command to `rad` (default: false)")
+
    );
+
    term::info!(
+
        "\t{} {}",
+
        term::format::tertiary(format!("{:-16}", "--json")),
+
        term::format::default("Print version as JSON")
+
    );
+
    term::info!(
+
        "\t{} {}",
+
        term::format::tertiary(format!("{:-16}", "--version")),
+
        term::format::default("Print version")
+
    );
+
    term::info!(
+
        "\t{} {}",
+
        term::format::tertiary(format!("{:-16}", "--help")),
+
        term::format::default("Print command specific help")
+
    );

+
    term::blank();
+
    println!("{}", term::format::secondary("Commands:").bold(),);
+

+
    term::info!(
+
        "\t{} {}",
+
        term::format::tertiary(format!("{:-16}", "version")),
+
        term::format::default("Print version")
+
    );
    for help in COMMANDS {
        term::info!(
            "\t{} {}",
-
            term::format::bold(format!("{:-12}", help.name)),
-
            term::format::dim(help.description)
+
            term::format::tertiary(format!("{:-16}", help.name)),
+
            term::format::default(help.description)
        );
    }
+

    term::blank();
-
    term::print("See `rad-tui <command> --help` to learn about a specific command.");
+
    println!(
+
        "See {} to learn about a specific command.",
+
        term::format::tertiary("`rad-tui <command> --help`")
+
    );
    term::blank();

    Ok(())
modified bin/commands/inbox.rs
@@ -1,7 +1,7 @@
#[path = "inbox/common.rs"]
mod common;
-
#[path = "inbox/select.rs"]
-
mod select;
+
#[path = "inbox/list.rs"]
+
mod list;

use std::ffi::OsString;

@@ -21,18 +21,23 @@ pub const HELP: Help = Help {
    usage: r#"
Usage

-
    rad-tui inbox select [<option>...]
+
    rad-tui inbox list [<option>...]

-
Other options
+
List options

    --mode <MODE>           Set selection mode; see MODE below (default: operation)
+
    --json                  Return JSON on stderr instead of calling `rad`
    
    --sort-by <field>       Sort by `id` or `timestamp` (default: timestamp)
    --reverse, -r           Reverse the list
-
    --help                  Print help

    The MODE argument can be 'operation' or 'id'. 'operation' selects a notification id and
    an operation, whereas 'id' selects a notification id only.
+

+
Other options
+

+
    --no-forward            Don't forward command to `rad` (default: true)
+
    --help                  Print help (enables forwarding)
"#,
};

@@ -41,40 +46,57 @@ pub struct Options {
}

pub enum Operation {
-
    Select { opts: SelectOptions },
+
    List { opts: ListOptions },
+
    Other { args: Vec<OsString> },
}

#[derive(PartialEq, Eq)]
pub enum OperationName {
-
    Select,
+
    List,
+
    Other,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub struct SelectOptions {
+
pub struct ListOptions {
    mode: Mode,
    filter: inbox::Filter,
    sort_by: inbox::SortBy,
+
    json: bool,
}

impl Args for Options {
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
        use lexopt::prelude::*;

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
+
        let mut parser = lexopt::Parser::from_args(args.clone());
+
        let mut op = OperationName::List;
+
        let mut forward = None;
+
        let mut json = false;
+
        let mut help = false;
        let mut repository_mode = None;
        let mut reverse = None;
        let mut field = None;
-
        let mut select_opts = SelectOptions::default();
+
        let mut list_opts = ListOptions::default();

        while let Some(arg) = parser.next()? {
            match arg {
+
                Long("no-forward") => {
+
                    forward = Some(false);
+
                }
+
                Long("json") => {
+
                    json = true;
+
                }
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
+
                    help = true;
+
                    // Only enable forwarding if it was not already disabled explicitly
+
                    forward = match forward {
+
                        Some(false) => Some(false),
+
                        _ => Some(true),
+
                    };
                }

-
                // select options.
-
                Long("mode") | Short('m') if op == Some(OperationName::Select) => {
+
                // list options.
+
                Long("mode") | Short('m') if op == OperationName::List => {
                    let val = parser.value()?;
                    let val = val.to_str().unwrap_or_default();

@@ -83,7 +105,7 @@ impl Args for Options {
                        "id" => SelectionMode::Id,
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
                    };
-
                    select_opts.mode = select_opts.mode.with_selection(selection_mode)
+
                    list_opts.mode = list_opts.mode.with_selection(selection_mode)
                }

                Long("reverse") | Short('r') => {
@@ -99,7 +121,7 @@ impl Args for Options {
                    }
                }

-
                Long("repo") if repository_mode.is_none() && op.is_some() => {
+
                Long("repo") if repository_mode.is_none() => {
                    let val = parser.value()?;
                    let repo = terminal::args::rid(&val)?;

@@ -109,18 +131,31 @@ impl Args for Options {
                    repository_mode = Some(RepositoryMode::All);
                }

-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "select" => op = Some(OperationName::Select),
-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
+
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
+
                    "list" => op = OperationName::List,
+
                    _ => op = OperationName::Other,
                },
-
                _ => return Err(anyhow!(arg.unexpected())),
+
                _ => {
+
                    if op == OperationName::List {
+
                        return Err(anyhow!(arg.unexpected()));
+
                    }
+
                }
            }
        }

-
        select_opts.mode = select_opts
+
        // Disable forwarding if it was not enabled via `--help` or was
+
        // not disabled explicitly.
+
        let forward = forward.unwrap_or_default();
+

+
        // Show local help
+
        if help && !forward {
+
            return Err(Error::Help.into());
+
        }
+

+
        list_opts.mode = list_opts
            .mode
            .with_repository(repository_mode.unwrap_or_default());
-
        select_opts.sort_by = if let Some(field) = field {
+
        list_opts.sort_by = if let Some(field) = field {
            inbox::SortBy {
                field,
                reverse: reverse.unwrap_or(false),
@@ -129,9 +164,14 @@ impl Args for Options {
            inbox::SortBy::default()
        };

-
        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
-
            OperationName::Select => Operation::Select { opts: select_opts },
+
        // Map local commands. Forward help and ignore `no-forward`.
+
        let op = match op {
+
            OperationName::List if !forward => Operation::List {
+
                opts: ListOptions { json, ..list_opts },
+
            },
+
            _ => Operation::Other { args },
        };
+

        Ok((Options { op }, vec![]))
    }
}
@@ -144,34 +184,191 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;

    match options.op {
-
        Operation::Select { opts } => {
+
        Operation::List { opts } => {
            let profile = ctx.profile()?;
            let repository = profile.storage.repository(rid).unwrap();

            if let Err(err) = crate::log::enable() {
                println!("{}", err);
            }
-
            log::info!("Starting patch selection interface in project {}..", rid);
+
            log::info!("Starting inbox listing interface in project {}..", rid);

-
            let context = select::Context {
+
            let context = list::Context {
                profile,
                repository,
                mode: opts.mode,
                filter: opts.filter.clone(),
                sort_by: opts.sort_by,
            };
-
            let output = select::App::new(context).run().await?;
+
            let selection = list::App::new(context).run().await?;

-
            let output = output
-
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
-
                .unwrap_or_default();
+
            if opts.json {
+
                let selection = selection
+
                    .map(|o| serde_json::to_string(&o).unwrap_or_default())
+
                    .unwrap_or_default();

-
            log::info!("About to print to `stderr`: {}", output);
-
            log::info!("Exiting inbox selection interface..");
+
                log::info!("About to print to `stderr`: {}", selection);
+
                log::info!("Exiting inbox listing interface..");

-
            eprint!("{output}");
+
                eprint!("{selection}");
+
            } else if let Some(selection) = selection {
+
                let mut args = vec![];
+

+
                if let Some(operation) = selection.operation {
+
                    args.push(operation.to_string());
+
                }
+
                if let Some(id) = selection.ids.first() {
+
                    args.push(format!("{id}"));
+
                }
+

+
                let args = args.into_iter().map(OsString::from).collect::<Vec<_>>();
+
                let _ = crate::terminal::run_rad("inbox", &args);
+
            }
+
        }
+
        Operation::Other { args } => {
+
            let _ = crate::terminal::run_rad("inbox", &args);
        }
    }

    Ok(())
}
+

+
#[cfg(test)]
+
mod cli {
+
    use std::process::Command;
+

+
    use assert_cmd::prelude::*;
+

+
    use predicates::prelude::*;
+

+
    mod assert {
+
        use predicates::prelude::*;
+
        use predicates::str::ContainsPredicate;
+

+
        pub fn is_tui() -> ContainsPredicate {
+
            predicate::str::contains("Inappropriate ioctl for device")
+
        }
+

+
        pub fn is_rad_manual() -> ContainsPredicate {
+
            predicate::str::contains("rad-inbox")
+
        }
+

+
        pub fn is_inbox_help() -> ContainsPredicate {
+
            predicate::str::contains("Terminal interfaces for notifications")
+
        }
+
    }
+

+
    #[test]
+
    // #[ignore = "breaks stdout"]
+
    fn empty_operation() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("inbox");
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("inbox");
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["inbox", "--help"]);
+
        cmd.assert().success().stdout(assert::is_rad_manual());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["inbox", "--help", "--no-forward"]);
+
        cmd.assert().success().stdout(assert::is_inbox_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["inbox", "--no-forward"]);
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["inbox", "list"]);
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["inbox", "list", "--no-forward"]);
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["inbox", "list", "--help"]);
+
        cmd.assert().success().stdout(assert::is_rad_manual());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["inbox", "list", "--help", "--no-forward"]);
+
        cmd.assert().success().stdout(assert::is_inbox_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_is_not_forwarded_reversed() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["inbox", "list", "--no-forward", "--help"]);
+
        cmd.assert().success().stdout(assert::is_inbox_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_show_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["inbox", "show"]);
+
        cmd.assert()
+
            .success()
+
            .stdout(predicate::str::contains("a Notification ID must be given"));
+

+
        Ok(())
+
    }
+
}
added bin/commands/inbox/list.rs
@@ -0,0 +1,447 @@
+
#[path = "list/ui.rs"]
+
mod ui;
+

+
use std::str::FromStr;
+

+
use anyhow::Result;
+

+
use ratatui::Viewport;
+
use termion::event::Key;
+

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::Text;
+

+
use radicle::identity::Project;
+
use radicle::node::notifications::NotificationId;
+
use radicle::storage::git::Repository;
+
use radicle::storage::ReadRepository;
+
use radicle::storage::ReadStorage;
+
use radicle::Profile;
+

+
use radicle_tui as tui;
+

+
use tui::store;
+
use tui::ui::rm::widget::container::{Container, Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::window::{
+
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
+
};
+
use tui::ui::rm::widget::{ToWidget, Widget};
+
use tui::ui::span;
+
use tui::ui::BufferedValue;
+
use tui::ui::Column;
+
use tui::{BoxedAny, Channel, Exit, PageStack};
+

+
use crate::cob::inbox;
+
use crate::ui::items::{Filter, NotificationItem, NotificationItemFilter};
+

+
use self::ui::Browser;
+
use self::ui::BrowserProps;
+

+
use super::common::SelectionMode;
+
use super::common::{Mode, RepositoryMode};
+

+
type Selection = tui::Selection<NotificationId>;
+

+
#[allow(dead_code)]
+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub mode: Mode,
+
    pub filter: inbox::Filter,
+
    pub sort_by: inbox::SortBy,
+
}
+

+
pub struct App {
+
    context: Context,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+
pub enum AppPage {
+
    Browse,
+
    Help,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct BrowserState {
+
    items: Vec<NotificationItem>,
+
    selected: Option<usize>,
+
    filter: NotificationItemFilter,
+
    search: BufferedValue<String>,
+
    show_search: bool,
+
}
+

+
impl BrowserState {
+
    pub fn notifications(&self) -> Vec<NotificationItem> {
+
        self.items
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .cloned()
+
            .collect()
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct HelpState {
+
    text: TextViewState,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct State {
+
    mode: Mode,
+
    project: Project,
+
    pages: PageStack<AppPage>,
+
    browser: BrowserState,
+
    help: HelpState,
+
}
+

+
impl TryFrom<&Context> for State {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let doc = context.repository.identity_doc()?;
+
        let project = doc.project()?;
+

+
        let search = BufferedValue::new(String::new());
+
        let filter = NotificationItemFilter::from_str(&search.read()).unwrap_or_default();
+

+
        let mut notifications = match &context.mode.repository() {
+
            RepositoryMode::All => {
+
                let mut repos = context.profile.storage.repositories()?;
+
                repos.sort_by_key(|r| r.rid);
+

+
                let mut notifs = vec![];
+
                for repo in repos {
+
                    let repo = context.profile.storage.repository(repo.rid)?;
+

+
                    let items = inbox::all(&repo, &context.profile)?
+
                        .iter()
+
                        .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
+
                        .filter_map(|item| item.ok())
+
                        .flatten()
+
                        .collect::<Vec<_>>();
+

+
                    notifs.extend(items);
+
                }
+

+
                notifs
+
            }
+
            RepositoryMode::Contextual => {
+
                let notifs = inbox::all(&context.repository, &context.profile)?;
+

+
                notifs
+
                    .iter()
+
                    .map(|notif| {
+
                        NotificationItem::new(&context.profile, &context.repository, notif)
+
                    })
+
                    .filter_map(|item| item.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
            RepositoryMode::ByRepo((rid, _)) => {
+
                let repo = context.profile.storage.repository(*rid)?;
+
                let notifs = inbox::all(&repo, &context.profile)?;
+

+
                notifs
+
                    .iter()
+
                    .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
+
                    .filter_map(|item| item.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
        };
+

+
        // Set project name
+
        let mode = match &context.mode.repository() {
+
            RepositoryMode::ByRepo((rid, _)) => {
+
                let project = context
+
                    .profile
+
                    .storage
+
                    .repository(*rid)?
+
                    .identity_doc()?
+
                    .project()?;
+
                let name = project.name().to_string();
+

+
                context
+
                    .mode
+
                    .clone()
+
                    .with_repository(RepositoryMode::ByRepo((*rid, Some(name))))
+
            }
+
            _ => context.mode.clone(),
+
        };
+

+
        // Apply sorting
+
        match context.sort_by.field {
+
            "timestamp" => notifications.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
+
            "id" => notifications.sort_by(|a, b| a.id.cmp(&b.id)),
+
            _ => {}
+
        }
+
        if context.sort_by.reverse {
+
            notifications.reverse();
+
        }
+

+
        // Sort by project if all notifications are shown
+
        if let RepositoryMode::All = mode.repository() {
+
            notifications.sort_by(|a, b| a.project.cmp(&b.project));
+
        }
+

+
        Ok(Self {
+
            mode: context.mode.clone(),
+
            project,
+
            pages: PageStack::new(vec![AppPage::Browse]),
+
            browser: BrowserState {
+
                items: notifications,
+
                selected: Some(0),
+
                filter,
+
                search,
+
                show_search: false,
+
            },
+
            help: HelpState {
+
                text: TextViewState::default().content(help_text()),
+
            },
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Exit { selection: Option<Selection> },
+
    Select { selected: Option<usize> },
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
+
    OpenHelp,
+
    LeavePage,
+
    ScrollHelp { state: TextViewState },
+
}
+

+
impl store::Update<Message> for State {
+
    type Return = Selection;
+

+
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
+
        match message {
+
            Message::Exit { selection } => Some(Exit { value: selection }),
+
            Message::Select { selected } => {
+
                self.browser.selected = selected;
+
                None
+
            }
+
            Message::OpenSearch => {
+
                self.browser.show_search = true;
+
                None
+
            }
+
            Message::UpdateSearch { value } => {
+
                self.browser.search.write(value);
+
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
+
                    .unwrap_or_default();
+

+
                if let Some(selected) = self.browser.selected {
+
                    if selected > self.browser.notifications().len() {
+
                        self.browser.selected = Some(0);
+
                    }
+
                }
+

+
                None
+
            }
+
            Message::ApplySearch => {
+
                self.browser.search.apply();
+
                self.browser.show_search = false;
+
                None
+
            }
+
            Message::CloseSearch => {
+
                self.browser.search.reset();
+
                self.browser.show_search = false;
+
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
+
                    .unwrap_or_default();
+

+
                None
+
            }
+
            Message::OpenHelp => {
+
                self.pages.push(AppPage::Help);
+
                None
+
            }
+
            Message::LeavePage => {
+
                self.pages.pop();
+
                None
+
            }
+
            Message::ScrollHelp { state } => {
+
                self.help.text = state;
+
                None
+
            }
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn new(context: Context) -> Self {
+
        Self { context }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let channel = Channel::default();
+
        let state = State::try_from(&self.context)?;
+
        let tx = channel.tx.clone();
+

+
        let window = Window::default()
+
            .page(AppPage::Browse, browser_page(&state, &channel))
+
            .page(AppPage::Help, help_page(&state, &channel))
+
            .to_widget(tx.clone())
+
            .on_update(|state: &State| {
+
                WindowProps::default()
+
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
+
                    .to_boxed_any()
+
                    .into()
+
            });
+

+
        tui::rm(state, window, Viewport::Inline(20), channel).await
+
    }
+
}
+

+
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Browser::new(tx.clone())
+
        .to_widget(tx.clone())
+
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
+

+
    let shortcuts = Shortcuts::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            let shortcuts = if state.browser.show_search {
+
                vec![("esc", "cancel"), ("enter", "apply")]
+
            } else {
+
                match state.mode.selection() {
+
                    SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
+
                    SelectionMode::Operation => vec![
+
                        ("enter", "show"),
+
                        ("c", "clear"),
+
                        ("/", "search"),
+
                        ("?", "help"),
+
                    ],
+
                }
+
            };
+

+
            ShortcutsProps::default()
+
                .shortcuts(&shortcuts)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, props| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+

+
            if props.handle_keys {
+
                match key {
+
                    Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    _ => None,
+
                }
+
            } else {
+
                None
+
            }
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .handle_keys(!state.browser.show_search)
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Container::default()
+
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
+
            HeaderProps::default()
+
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
+
                .to_boxed_any()
+
                .into()
+
        }))
+
        .content(
+
            TextView::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, vs, _| {
+
                    vs.and_then(|vs| vs.unwrap_textview())
+
                        .map(|tvs| Message::ScrollHelp { state: tvs })
+
                })
+
                .on_update(|state: &State| {
+
                    TextViewProps::default()
+
                        .state(Some(state.help.text.clone()))
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .footer(
+
            Footer::default()
+
                .to_widget(tx.clone())
+
                .on_update(|state: &State| {
+
                    FooterProps::default()
+
                        .columns(
+
                            [
+
                                Column::new(Text::raw(""), Constraint::Fill(1)),
+
                                Column::new(
+
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
+
                                    Constraint::Min(4),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone());
+

+
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
+
        ShortcutsProps::default()
+
            .shortcuts(&[("?", "close")])
+
            .to_boxed_any()
+
            .into()
+
    });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
            Key::Char('?') => Some(Message::LeavePage),
+
            _ => None,
+
        })
+
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
+
}
+

+
fn help_text() -> String {
+
    r#"# Generic keybindings
+

+
`↑,k`:      move cursor one line up
+
`↓,j:       move cursor one line down
+
`PageUp`:   move cursor one page up
+
`PageDown`: move cursor one page down
+
`Home`:     move cursor to the first line
+
`End`:      move cursor to the last line
+
`Esc`:      Quit / cancel
+

+
# Specific keybindings
+

+
`enter`:    Select notification (if --mode id)
+
`enter`:    Show notification
+
`c`:        Clear notifications
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:patch | is:issue | <search>
+
Example:    is:unseen is:patch Print"#
+
        .into()
+
}
added bin/commands/inbox/list/ui.rs
@@ -0,0 +1,328 @@
+
use std::collections::HashMap;
+
use std::str::FromStr;
+

+
use ratatui::Frame;
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Text};
+

+
use radicle_tui as tui;
+

+
use tui::ui::rm::widget::container::{
+
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
};
+
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
+
use tui::ui::rm::widget::list::{Table, TableProps};
+
use tui::ui::rm::widget::{self, ViewProps};
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::span;
+
use tui::ui::Column;
+

+
use tui::{BoxedAny, Selection};
+

+
use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
+
use crate::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
+

+
use super::{Message, State};
+

+
type Widget = widget::Widget<State, Message>;
+

+
#[derive(Clone, Default)]
+
pub struct BrowserProps<'a> {
+
    /// Application mode: openation and id or id only.
+
    mode: Mode,
+
    /// Table title
+
    header: String,
+
    /// Filtered notifications.
+
    notifications: Vec<NotificationItem>,
+
    /// Current (selected) table index
+
    selected: Option<usize>,
+
    /// Notification statistics.
+
    stats: HashMap<String, usize>,
+
    /// Table columns
+
    columns: Vec<Column<'a>>,
+
    /// If search widget should be shown.
+
    show_search: bool,
+
    /// Current search string.
+
    search: String,
+
}
+

+
impl<'a> From<&State> for BrowserProps<'a> {
+
    fn from(state: &State) -> Self {
+
        let header = match state.mode.repository() {
+
            RepositoryMode::Contextual => state.project.name().to_string(),
+
            RepositoryMode::All => "All repositories".to_string(),
+
            RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(),
+
        };
+

+
        let notifications = state.browser.notifications();
+

+
        // Compute statistics
+
        let mut seen = 0;
+
        let mut unseen = 0;
+
        for notification in &notifications {
+
            if notification.seen {
+
                seen += 1;
+
            } else {
+
                unseen += 1;
+
            }
+
        }
+
        let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]);
+

+
        Self {
+
            mode: state.mode.clone(),
+
            header,
+
            notifications,
+
            selected: state.browser.selected,
+
            stats,
+
            columns: [
+
                Column::new("", Constraint::Length(5)),
+
                Column::new("", Constraint::Length(3)),
+
                Column::new("", Constraint::Fill(5)),
+
                Column::new("", Constraint::Fill(1))
+
                    .skip(*state.mode.repository() != RepositoryMode::All),
+
                Column::new("", Constraint::Fill(1))
+
                    .hide_small()
+
                    .hide_medium(),
+
                Column::new("", Constraint::Length(8)),
+
                Column::new("", Constraint::Length(10)),
+
                Column::new("", Constraint::Min(12)).hide_small(),
+
                Column::new("", Constraint::Min(14)).hide_small(),
+
            ]
+
            .to_vec(),
+
            search: state.browser.search.read(),
+
            show_search: state.browser.show_search,
+
        }
+
    }
+
}
+

+
pub struct Browser {
+
    /// Notification widget
+
    notifications: Widget,
+
    /// Search widget
+
    search: Widget,
+
}
+

+
impl Browser {
+
    pub fn new(tx: UnboundedSender<Message>) -> Self {
+
        Self {
+
            notifications: Container::default()
+
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+
                    HeaderProps::default()
+
                        .columns(
+
                            [
+
                                Column::new("", Constraint::Length(0)),
+
                                Column::new(Text::from(props.header), Constraint::Fill(1)),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(
+
                    Table::<State, Message, NotificationItem, 9>::default()
+
                        .to_widget(tx.clone())
+
                        .on_event(|_, s, _| {
+
                            let (selected, _) =
+
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
+
                            Some(Message::Select {
+
                                selected: Some(selected),
+
                            })
+
                        })
+
                        .on_update(|state| {
+
                            let props = BrowserProps::from(state);
+

+
                            TableProps::default()
+
                                .columns(props.columns)
+
                                .items(state.browser.notifications())
+
                                .selected(state.browser.selected)
+
                                .to_boxed_any()
+
                                .into()
+
                        }),
+
                )
+
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
+
                    let props = BrowserProps::from(state);
+

+
                    FooterProps::default()
+
                        .columns(browse_footer(&props))
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .to_widget(tx.clone())
+
                .on_update(|state| {
+
                    ContainerProps::default()
+
                        .hide_footer(BrowserProps::from(state).show_search)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
            search: TextField::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, s, _| {
+
                    Some(Message::UpdateSearch {
+
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    TextFieldProps::default()
+
                        .text(&state.browser.search.read().to_string())
+
                        .title("Search")
+
                        .inline(true)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        }
+
    }
+
}
+

+
impl View for Browser {
+
    type Message = Message;
+
    type State = State;
+

+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
+
            match key {
+
                Key::Esc => {
+
                    self.search.reset();
+
                    Some(Message::CloseSearch)
+
                }
+
                Key::Char('\n') => Some(Message::ApplySearch),
+
                _ => {
+
                    self.search.handle_event(key);
+
                    None
+
                }
+
            }
+
        } else {
+
            match key {
+
                Key::Char('/') => Some(Message::OpenSearch),
+
                Key::Char('\n') => props
+
                    .selected
+
                    .and_then(|selected| props.notifications.get(selected))
+
                    .map(|notif| {
+
                        let selection = match props.mode.selection() {
+
                            SelectionMode::Operation => Selection::default()
+
                                .with_operation(InboxOperation::Show.to_string())
+
                                .with_id(notif.id),
+
                            SelectionMode::Id => Selection::default().with_id(notif.id),
+
                        };
+

+
                        Message::Exit {
+
                            selection: Some(selection),
+
                        }
+
                    }),
+
                Key::Char('c') => props
+
                    .selected
+
                    .and_then(|selected| props.notifications.get(selected))
+
                    .map(|notif| Message::Exit {
+
                        selection: Some(
+
                            Selection::default()
+
                                .with_operation(InboxOperation::Clear.to_string())
+
                                .with_id(notif.id),
+
                        ),
+
                    }),
+
                _ => {
+
                    self.notifications.handle_event(key);
+
                    None
+
                }
+
            }
+
        }
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        self.notifications.update(state);
+
        self.search.update(state);
+
    }
+

+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
+
            let [_, search_area, _] = Layout::horizontal([
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
                Constraint::Length(1),
+
            ])
+
            .areas(search_area);
+

+
            self.notifications
+
                .render(RenderProps::from(table_area), frame);
+
            self.search
+
                .render(RenderProps::from(search_area).focus(render.focus), frame);
+
        } else {
+
            self.notifications.render(render, frame);
+
        }
+
    }
+
}
+

+
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
+
    let search = Line::from(vec![
+
        span::default(" Search ").cyan().dim().reversed(),
+
        span::default(" "),
+
        span::default(&props.search.to_string()).gray().dim(),
+
    ]);
+

+
    let seen = Line::from(vec![
+
        span::positive(&props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
+
        span::default(" Seen").dim(),
+
    ]);
+
    let unseen = Line::from(vec![
+
        span::positive(&props.stats.get("Unseen").unwrap_or(&0).to_string())
+
            .magenta()
+
            .dim(),
+
        span::default(" Unseen").dim(),
+
    ]);
+
    let sum = Line::from(vec![
+
        span::default("Σ ").dim(),
+
        span::default(&props.notifications.len().to_string()).dim(),
+
    ]);
+

+
    match NotificationItemFilter::from_str(&props.search)
+
        .unwrap_or_default()
+
        .state()
+
    {
+
        Some(state) => {
+
            let block = match state {
+
                NotificationState::Seen => seen,
+
                NotificationState::Unseen => unseen,
+
            };
+

+
            [
+
                Column::new(Text::from(search), Constraint::Fill(1)),
+
                Column::new(
+
                    Text::from(block.clone()),
+
                    Constraint::Min(block.width() as u16),
+
                ),
+
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
            ]
+
            .to_vec()
+
        }
+
        None => [
+
            Column::new(Text::from(search), Constraint::Fill(1)),
+
            Column::new(
+
                Text::from(seen.clone()),
+
                Constraint::Min(seen.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(unseen.clone()),
+
                Constraint::Min(unseen.width() as u16),
+
            ),
+
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
        ]
+
        .to_vec(),
+
    }
+
}
deleted bin/commands/inbox/select.rs
@@ -1,447 +0,0 @@
-
#[path = "select/ui.rs"]
-
mod ui;
-

-
use std::str::FromStr;
-

-
use anyhow::Result;
-

-
use ratatui::Viewport;
-
use termion::event::Key;
-

-
use ratatui::layout::Constraint;
-
use ratatui::style::Stylize;
-
use ratatui::text::Text;
-

-
use radicle::identity::Project;
-
use radicle::node::notifications::NotificationId;
-
use radicle::storage::git::Repository;
-
use radicle::storage::ReadRepository;
-
use radicle::storage::ReadStorage;
-
use radicle::Profile;
-

-
use radicle_tui as tui;
-

-
use tui::store;
-
use tui::ui::rm::widget::container::{Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::rm::widget::window::{
-
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
-
};
-
use tui::ui::rm::widget::{ToWidget, Widget};
-
use tui::ui::span;
-
use tui::ui::BufferedValue;
-
use tui::ui::Column;
-
use tui::{BoxedAny, Channel, Exit, PageStack};
-

-
use crate::cob::inbox;
-
use crate::ui::items::{Filter, NotificationItem, NotificationItemFilter};
-

-
use self::ui::Browser;
-
use self::ui::BrowserProps;
-

-
use super::common::SelectionMode;
-
use super::common::{Mode, RepositoryMode};
-

-
type Selection = tui::Selection<NotificationId>;
-

-
#[allow(dead_code)]
-
pub struct Context {
-
    pub profile: Profile,
-
    pub repository: Repository,
-
    pub mode: Mode,
-
    pub filter: inbox::Filter,
-
    pub sort_by: inbox::SortBy,
-
}
-

-
pub struct App {
-
    context: Context,
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum AppPage {
-
    Browse,
-
    Help,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct BrowserState {
-
    items: Vec<NotificationItem>,
-
    selected: Option<usize>,
-
    filter: NotificationItemFilter,
-
    search: BufferedValue<String>,
-
    show_search: bool,
-
}
-

-
impl BrowserState {
-
    pub fn notifications(&self) -> Vec<NotificationItem> {
-
        self.items
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .cloned()
-
            .collect()
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct HelpState {
-
    text: TextViewState,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct State {
-
    mode: Mode,
-
    project: Project,
-
    pages: PageStack<AppPage>,
-
    browser: BrowserState,
-
    help: HelpState,
-
}
-

-
impl TryFrom<&Context> for State {
-
    type Error = anyhow::Error;
-

-
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
-
        let doc = context.repository.identity_doc()?;
-
        let project = doc.project()?;
-

-
        let search = BufferedValue::new(String::new());
-
        let filter = NotificationItemFilter::from_str(&search.read()).unwrap_or_default();
-

-
        let mut notifications = match &context.mode.repository() {
-
            RepositoryMode::All => {
-
                let mut repos = context.profile.storage.repositories()?;
-
                repos.sort_by_key(|r| r.rid);
-

-
                let mut notifs = vec![];
-
                for repo in repos {
-
                    let repo = context.profile.storage.repository(repo.rid)?;
-

-
                    let items = inbox::all(&repo, &context.profile)?
-
                        .iter()
-
                        .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
-
                        .filter_map(|item| item.ok())
-
                        .flatten()
-
                        .collect::<Vec<_>>();
-

-
                    notifs.extend(items);
-
                }
-

-
                notifs
-
            }
-
            RepositoryMode::Contextual => {
-
                let notifs = inbox::all(&context.repository, &context.profile)?;
-

-
                notifs
-
                    .iter()
-
                    .map(|notif| {
-
                        NotificationItem::new(&context.profile, &context.repository, notif)
-
                    })
-
                    .filter_map(|item| item.ok())
-
                    .flatten()
-
                    .collect::<Vec<_>>()
-
            }
-
            RepositoryMode::ByRepo((rid, _)) => {
-
                let repo = context.profile.storage.repository(*rid)?;
-
                let notifs = inbox::all(&repo, &context.profile)?;
-

-
                notifs
-
                    .iter()
-
                    .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
-
                    .filter_map(|item| item.ok())
-
                    .flatten()
-
                    .collect::<Vec<_>>()
-
            }
-
        };
-

-
        // Set project name
-
        let mode = match &context.mode.repository() {
-
            RepositoryMode::ByRepo((rid, _)) => {
-
                let project = context
-
                    .profile
-
                    .storage
-
                    .repository(*rid)?
-
                    .identity_doc()?
-
                    .project()?;
-
                let name = project.name().to_string();
-

-
                context
-
                    .mode
-
                    .clone()
-
                    .with_repository(RepositoryMode::ByRepo((*rid, Some(name))))
-
            }
-
            _ => context.mode.clone(),
-
        };
-

-
        // Apply sorting
-
        match context.sort_by.field {
-
            "timestamp" => notifications.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
-
            "id" => notifications.sort_by(|a, b| a.id.cmp(&b.id)),
-
            _ => {}
-
        }
-
        if context.sort_by.reverse {
-
            notifications.reverse();
-
        }
-

-
        // Sort by project if all notifications are shown
-
        if let RepositoryMode::All = mode.repository() {
-
            notifications.sort_by(|a, b| a.project.cmp(&b.project));
-
        }
-

-
        Ok(Self {
-
            mode: context.mode.clone(),
-
            project,
-
            pages: PageStack::new(vec![AppPage::Browse]),
-
            browser: BrowserState {
-
                items: notifications,
-
                selected: Some(0),
-
                filter,
-
                search,
-
                show_search: false,
-
            },
-
            help: HelpState {
-
                text: TextViewState::default().content(help_text()),
-
            },
-
        })
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Exit { selection: Option<Selection> },
-
    Select { selected: Option<usize> },
-
    OpenSearch,
-
    UpdateSearch { value: String },
-
    ApplySearch,
-
    CloseSearch,
-
    OpenHelp,
-
    LeavePage,
-
    ScrollHelp { state: TextViewState },
-
}
-

-
impl store::Update<Message> for State {
-
    type Return = Selection;
-

-
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
-
        match message {
-
            Message::Exit { selection } => Some(Exit { value: selection }),
-
            Message::Select { selected } => {
-
                self.browser.selected = selected;
-
                None
-
            }
-
            Message::OpenSearch => {
-
                self.browser.show_search = true;
-
                None
-
            }
-
            Message::UpdateSearch { value } => {
-
                self.browser.search.write(value);
-
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
-
                    .unwrap_or_default();
-

-
                if let Some(selected) = self.browser.selected {
-
                    if selected > self.browser.notifications().len() {
-
                        self.browser.selected = Some(0);
-
                    }
-
                }
-

-
                None
-
            }
-
            Message::ApplySearch => {
-
                self.browser.search.apply();
-
                self.browser.show_search = false;
-
                None
-
            }
-
            Message::CloseSearch => {
-
                self.browser.search.reset();
-
                self.browser.show_search = false;
-
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
-
                    .unwrap_or_default();
-

-
                None
-
            }
-
            Message::OpenHelp => {
-
                self.pages.push(AppPage::Help);
-
                None
-
            }
-
            Message::LeavePage => {
-
                self.pages.pop();
-
                None
-
            }
-
            Message::ScrollHelp { state } => {
-
                self.help.text = state;
-
                None
-
            }
-
        }
-
    }
-
}
-

-
impl App {
-
    pub fn new(context: Context) -> Self {
-
        Self { context }
-
    }
-

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let channel = Channel::default();
-
        let state = State::try_from(&self.context)?;
-
        let tx = channel.tx.clone();
-

-
        let window = Window::default()
-
            .page(AppPage::Browse, browser_page(&state, &channel))
-
            .page(AppPage::Help, help_page(&state, &channel))
-
            .to_widget(tx.clone())
-
            .on_update(|state: &State| {
-
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
-
                    .to_boxed_any()
-
                    .into()
-
            });
-

-
        tui::rm(state, window, Viewport::Inline(20), channel).await
-
    }
-
}
-

-
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Browser::new(tx.clone())
-
        .to_widget(tx.clone())
-
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
-

-
    let shortcuts = Shortcuts::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state: &State| {
-
            let shortcuts = if state.browser.show_search {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                match state.mode.selection() {
-
                    SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
-
                    SelectionMode::Operation => vec![
-
                        ("enter", "show"),
-
                        ("c", "clear"),
-
                        ("/", "search"),
-
                        ("?", "help"),
-
                    ],
-
                }
-
            };
-

-
            ShortcutsProps::default()
-
                .shortcuts(&shortcuts)
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, props| {
-
            let default = PageProps::default();
-
            let props = props
-
                .and_then(|props| props.inner_ref::<PageProps>())
-
                .unwrap_or(&default);
-

-
            if props.handle_keys {
-
                match key {
-
                    Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
-
                    Key::Char('?') => Some(Message::OpenHelp),
-
                    _ => None,
-
                }
-
            } else {
-
                None
-
            }
-
        })
-
        .on_update(|state: &State| {
-
            PageProps::default()
-
                .handle_keys(!state.browser.show_search)
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

-
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Container::default()
-
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
-
            HeaderProps::default()
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .to_boxed_any()
-
                .into()
-
        }))
-
        .content(
-
            TextView::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, vs, _| {
-
                    vs.and_then(|vs| vs.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp { state: tvs })
-
                })
-
                .on_update(|state: &State| {
-
                    TextViewProps::default()
-
                        .state(Some(state.help.text.clone()))
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .footer(
-
            Footer::default()
-
                .to_widget(tx.clone())
-
                .on_update(|state: &State| {
-
                    FooterProps::default()
-
                        .columns(
-
                            [
-
                                Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
-
                                    Constraint::Min(4),
-
                                ),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone());
-

-
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
-
        ShortcutsProps::default()
-
            .shortcuts(&[("?", "close")])
-
            .to_boxed_any()
-
            .into()
-
    });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, _| match key {
-
            Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
-
            Key::Char('?') => Some(Message::LeavePage),
-
            _ => None,
-
        })
-
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
-
}
-

-
fn help_text() -> String {
-
    r#"# Generic keybindings
-

-
`↑,k`:      move cursor one line up
-
`↓,j:       move cursor one line down
-
`PageUp`:   move cursor one page up
-
`PageDown`: move cursor one page down
-
`Home`:     move cursor to the first line
-
`End`:      move cursor to the last line
-
`Esc`:      Quit / cancel
-

-
# Specific keybindings
-

-
`enter`:    Select notification (if --mode id)
-
`enter`:    Show notification
-
`c`:        Clear notifications
-
`/`:        Search
-
`?`:        Show help
-

-
# Searching
-

-
Pattern:    is:<state> | is:patch | is:issue | <search>
-
Example:    is:unseen is:patch Print"#
-
        .into()
-
}
deleted bin/commands/inbox/select/ui.rs
@@ -1,328 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-

-
use ratatui::Frame;
-
use tokio::sync::mpsc::UnboundedSender;
-

-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Text};
-

-
use radicle_tui as tui;
-

-
use tui::ui::rm::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
-
};
-
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
-
use tui::ui::rm::widget::list::{Table, TableProps};
-
use tui::ui::rm::widget::{self, ViewProps};
-
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
-
use tui::ui::span;
-
use tui::ui::Column;
-

-
use tui::{BoxedAny, Selection};
-

-
use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
-
use crate::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
-

-
use super::{Message, State};
-

-
type Widget = widget::Widget<State, Message>;
-

-
#[derive(Clone, Default)]
-
pub struct BrowserProps<'a> {
-
    /// Application mode: openation and id or id only.
-
    mode: Mode,
-
    /// Table title
-
    header: String,
-
    /// Filtered notifications.
-
    notifications: Vec<NotificationItem>,
-
    /// Current (selected) table index
-
    selected: Option<usize>,
-
    /// Notification statistics.
-
    stats: HashMap<String, usize>,
-
    /// Table columns
-
    columns: Vec<Column<'a>>,
-
    /// If search widget should be shown.
-
    show_search: bool,
-
    /// Current search string.
-
    search: String,
-
}
-

-
impl<'a> From<&State> for BrowserProps<'a> {
-
    fn from(state: &State) -> Self {
-
        let header = match state.mode.repository() {
-
            RepositoryMode::Contextual => state.project.name().to_string(),
-
            RepositoryMode::All => "All repositories".to_string(),
-
            RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(),
-
        };
-

-
        let notifications = state.browser.notifications();
-

-
        // Compute statistics
-
        let mut seen = 0;
-
        let mut unseen = 0;
-
        for notification in &notifications {
-
            if notification.seen {
-
                seen += 1;
-
            } else {
-
                unseen += 1;
-
            }
-
        }
-
        let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]);
-

-
        Self {
-
            mode: state.mode.clone(),
-
            header,
-
            notifications,
-
            selected: state.browser.selected,
-
            stats,
-
            columns: [
-
                Column::new("", Constraint::Length(5)),
-
                Column::new("", Constraint::Length(3)),
-
                Column::new("", Constraint::Fill(5)),
-
                Column::new("", Constraint::Fill(1))
-
                    .skip(*state.mode.repository() != RepositoryMode::All),
-
                Column::new("", Constraint::Fill(1))
-
                    .hide_small()
-
                    .hide_medium(),
-
                Column::new("", Constraint::Length(8)),
-
                Column::new("", Constraint::Length(10)),
-
                Column::new("", Constraint::Min(12)).hide_small(),
-
                Column::new("", Constraint::Min(14)).hide_small(),
-
            ]
-
            .to_vec(),
-
            search: state.browser.search.read(),
-
            show_search: state.browser.show_search,
-
        }
-
    }
-
}
-

-
pub struct Browser {
-
    /// Notification widget
-
    notifications: Widget,
-
    /// Search widget
-
    search: Widget,
-
}
-

-
impl Browser {
-
    pub fn new(tx: UnboundedSender<Message>) -> Self {
-
        Self {
-
            notifications: Container::default()
-
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-
                    HeaderProps::default()
-
                        .columns(
-
                            [
-
                                Column::new("", Constraint::Length(0)),
-
                                Column::new(Text::from(props.header), Constraint::Fill(1)),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(
-
                    Table::<State, Message, NotificationItem, 9>::default()
-
                        .to_widget(tx.clone())
-
                        .on_event(|_, s, _| {
-
                            let (selected, _) =
-
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::Select {
-
                                selected: Some(selected),
-
                            })
-
                        })
-
                        .on_update(|state| {
-
                            let props = BrowserProps::from(state);
-

-
                            TableProps::default()
-
                                .columns(props.columns)
-
                                .items(state.browser.notifications())
-
                                .selected(state.browser.selected)
-
                                .to_boxed_any()
-
                                .into()
-
                        }),
-
                )
-
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
-
                    let props = BrowserProps::from(state);
-

-
                    FooterProps::default()
-
                        .columns(browse_footer(&props))
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    ContainerProps::default()
-
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
            search: TextField::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    Some(Message::UpdateSearch {
-
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.search.read().to_string())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        }
-
    }
-
}
-

-
impl View for Browser {
-
    type Message = Message;
-
    type State = State;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            match key {
-
                Key::Esc => {
-
                    self.search.reset();
-
                    Some(Message::CloseSearch)
-
                }
-
                Key::Char('\n') => Some(Message::ApplySearch),
-
                _ => {
-
                    self.search.handle_event(key);
-
                    None
-
                }
-
            }
-
        } else {
-
            match key {
-
                Key::Char('/') => Some(Message::OpenSearch),
-
                Key::Char('\n') => props
-
                    .selected
-
                    .and_then(|selected| props.notifications.get(selected))
-
                    .map(|notif| {
-
                        let selection = match props.mode.selection() {
-
                            SelectionMode::Operation => Selection::default()
-
                                .with_operation(InboxOperation::Show.to_string())
-
                                .with_id(notif.id),
-
                            SelectionMode::Id => Selection::default().with_id(notif.id),
-
                        };
-

-
                        Message::Exit {
-
                            selection: Some(selection),
-
                        }
-
                    }),
-
                Key::Char('c') => props
-
                    .selected
-
                    .and_then(|selected| props.notifications.get(selected))
-
                    .map(|notif| Message::Exit {
-
                        selection: Some(
-
                            Selection::default()
-
                                .with_operation(InboxOperation::Clear.to_string())
-
                                .with_id(notif.id),
-
                        ),
-
                    }),
-
                _ => {
-
                    self.notifications.handle_event(key);
-
                    None
-
                }
-
            }
-
        }
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        self.notifications.update(state);
-
        self.search.update(state);
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
-
            let [_, search_area, _] = Layout::horizontal([
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
                Constraint::Length(1),
-
            ])
-
            .areas(search_area);
-

-
            self.notifications
-
                .render(RenderProps::from(table_area), frame);
-
            self.search
-
                .render(RenderProps::from(search_area).focus(render.focus), frame);
-
        } else {
-
            self.notifications.render(render, frame);
-
        }
-
    }
-
}
-

-
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
-
    let search = Line::from(vec![
-
        span::default(" Search ").cyan().dim().reversed(),
-
        span::default(" "),
-
        span::default(&props.search.to_string()).gray().dim(),
-
    ]);
-

-
    let seen = Line::from(vec![
-
        span::positive(&props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Seen").dim(),
-
    ]);
-
    let unseen = Line::from(vec![
-
        span::positive(&props.stats.get("Unseen").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Unseen").dim(),
-
    ]);
-
    let sum = Line::from(vec![
-
        span::default("Σ ").dim(),
-
        span::default(&props.notifications.len().to_string()).dim(),
-
    ]);
-

-
    match NotificationItemFilter::from_str(&props.search)
-
        .unwrap_or_default()
-
        .state()
-
    {
-
        Some(state) => {
-
            let block = match state {
-
                NotificationState::Seen => seen,
-
                NotificationState::Unseen => unseen,
-
            };
-

-
            [
-
                Column::new(Text::from(search), Constraint::Fill(1)),
-
                Column::new(
-
                    Text::from(block.clone()),
-
                    Constraint::Min(block.width() as u16),
-
                ),
-
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
            ]
-
            .to_vec()
-
        }
-
        None => [
-
            Column::new(Text::from(search), Constraint::Fill(1)),
-
            Column::new(
-
                Text::from(seen.clone()),
-
                Constraint::Min(seen.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(unseen.clone()),
-
                Constraint::Min(unseen.width() as u16),
-
            ),
-
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
        ]
-
        .to_vec(),
-
    }
-
}
modified bin/commands/issue.rs
@@ -1,7 +1,7 @@
#[path = "issue/common.rs"]
mod common;
-
#[path = "issue/select.rs"]
-
mod select;
+
#[path = "issue/list.rs"]
+
mod list;

use std::ffi::OsString;

@@ -31,18 +31,20 @@ pub const HELP: Help = Help {
    usage: r#"
Usage

-
    rad-tui patch select [<option>...]
+
    rad-tui issue list [<option>...]

-
Select options
+
List options

-
    --mode <MODE>           Set selection mode; see MODE below (default: operation)
+
    --mode <MODE>       Set selection mode; see MODE below (default: operation)
+
    --json              Return JSON on stderr instead of calling `rad`

    The MODE argument can be 'operation' or 'id'. 'operation' selects an issue id and
    an operation, whereas 'id' selects an issue id only.

Other options

-
    --help               Print help
+
    --no-forward        Don't forward command to `rad` (default: true)
+
    --help              Print help (enables forwarding)
"#,
};

@@ -52,70 +54,85 @@ pub struct Options {
}

pub enum Operation {
-
    Select { opts: SelectOptions },
+
    List { opts: ListOptions },
+
    Other { args: Vec<OsString> },
}

#[derive(PartialEq, Eq)]
pub enum OperationName {
-
    Select,
+
    List,
+
    Other,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub struct SelectOptions {
+
pub struct ListOptions {
    mode: common::Mode,
    filter: cob::issue::Filter,
+
    json: bool,
}

impl Args for Options {
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
        use lexopt::prelude::*;

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
+
        let mut parser = lexopt::Parser::from_args(args.clone());
+
        let mut op = OperationName::List;
        let mut repo = None;
-
        let mut select_opts = SelectOptions::default();
+
        let mut forward = None;
+
        let mut json = false;
+
        let mut help = false;
+
        let mut list_opts = ListOptions::default();

        while let Some(arg) = parser.next()? {
            match arg {
+
                Long("no-forward") => {
+
                    forward = Some(false);
+
                }
+
                Long("json") => {
+
                    json = true;
+
                }
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
+
                    help = true;
+
                    // Only enable forwarding if it was not already disabled explicitly
+
                    forward = match forward {
+
                        Some(false) => Some(false),
+
                        _ => Some(true),
+
                    };
                }

                // select options.
-
                Long("mode") | Short('m') if op == Some(OperationName::Select) => {
+
                Long("mode") | Short('m') if op == OperationName::List => {
                    let val = parser.value()?;
                    let val = val.to_str().unwrap_or_default();

-
                    select_opts.mode = match val {
+
                    list_opts.mode = match val {
                        "operation" => common::Mode::Operation,
                        "id" => common::Mode::Id,
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
                    };
                }
-
                Long("all") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(None);
+
                Long("all") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(None);
                }
-
                Long("open") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(Some(issue::State::Open));
+
                Long("open") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Open));
                }
-
                Long("solved") if op == Some(OperationName::Select) => {
-
                    select_opts.filter =
-
                        select_opts.filter.with_state(Some(issue::State::Closed {
-
                            reason: issue::CloseReason::Solved,
-
                        }));
+
                Long("solved") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Closed {
+
                        reason: issue::CloseReason::Solved,
+
                    }));
                }
-
                Long("closed") if op == Some(OperationName::Select) => {
-
                    select_opts.filter =
-
                        select_opts.filter.with_state(Some(issue::State::Closed {
-
                            reason: issue::CloseReason::Other,
-
                        }));
+
                Long("closed") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Closed {
+
                        reason: issue::CloseReason::Other,
+
                    }));
                }
-
                Long("assigned") if op == Some(OperationName::Select) => {
+
                Long("assigned") if op == OperationName::List => {
                    if let Ok(val) = parser.value() {
-
                        select_opts.filter =
-
                            select_opts.filter.with_assginee(terminal::args::did(&val)?);
+
                        list_opts.filter =
+
                            list_opts.filter.with_assginee(terminal::args::did(&val)?);
                    } else {
-
                        select_opts.filter = select_opts.filter.with_assgined(true);
+
                        list_opts.filter = list_opts.filter.with_assgined(true);
                    }
                }

@@ -126,17 +143,35 @@ impl Args for Options {
                    repo = Some(rid);
                }

-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "select" => op = Some(OperationName::Select),
-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
+
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
+
                    "list" => op = OperationName::List,
+
                    _ => op = OperationName::Other,
                },
-
                _ => return Err(anyhow!(arg.unexpected())),
+
                _ => {
+
                    if op == OperationName::List {
+
                        return Err(anyhow!(arg.unexpected()));
+
                    }
+
                }
            }
        }

-
        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
-
            OperationName::Select => Operation::Select { opts: select_opts },
+
        // Disable forwarding if it was not enabled via `--help` or was
+
        // not disabled explicitly.
+
        let forward = forward.unwrap_or_default();
+

+
        // Show local help
+
        if help && !forward {
+
            return Err(Error::Help.into());
+
        }
+

+
        // Map local commands. Forward help and ignore `no-forward`.
+
        let op = match op {
+
            OperationName::List if !forward => Operation::List {
+
                opts: ListOptions { json, ..list_opts },
+
            },
+
            _ => Operation::Other { args },
        };
+

        Ok((Options { op, repo }, vec![]))
    }
}
@@ -151,7 +186,7 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
    let terminal_info = TERMINAL_INFO.clone();

    match options.op {
-
        Operation::Select { opts } => {
+
        Operation::List { opts } => {
            let profile = ctx.profile()?;
            let rid = options.repo.unwrap_or(rid);
            let repository = profile.storage.repository(rid).unwrap();
@@ -159,27 +194,220 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
            if let Err(err) = crate::log::enable() {
                println!("{}", err);
            }
-
            log::info!("Starting issue selection interface in project {}..", rid);
+
            log::info!("Starting issue listing interface in project {}..", rid);

-
            let context = select::Context {
+
            let context = list::Context {
                profile,
                repository,
                mode: opts.mode,
                filter: opts.filter.clone(),
            };

-
            let output = select::App::new(context, terminal_info).run().await?;
+
            let selection = list::App::new(context, terminal_info).run().await?;
+

+
            if opts.json {
+
                let selection = selection
+
                    .map(|o| serde_json::to_string(&o).unwrap_or_default())
+
                    .unwrap_or_default();

-
            let output = output
-
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
-
                .unwrap_or_default();
+
                log::info!("About to print to `stderr`: {}", selection);
+
                log::info!("Exiting issue listing interface..");

-
            log::info!("About to print to `stderr`: {}", output);
-
            log::info!("Exiting issue selection interface..");
+
                eprint!("{selection}");
+
            } else if let Some(selection) = selection {
+
                let mut args = vec![];

-
            eprint!("{output}");
+
                if let Some(operation) = selection.operation {
+
                    args.push(operation.to_string());
+
                }
+
                if let Some(id) = selection.ids.first() {
+
                    args.push(format!("{id}"));
+
                }
+

+
                let args = args.into_iter().map(OsString::from).collect::<Vec<_>>();
+
                let _ = crate::terminal::run_rad("issue", &args);
+
            }
+
        }
+
        Operation::Other { args } => {
+
            let _ = crate::terminal::run_rad("issue", &args);
        }
    }

    Ok(())
}
+

+
#[cfg(test)]
+
mod cli {
+
    use std::process::Command;
+

+
    use assert_cmd::prelude::*;
+

+
    use predicates::prelude::*;
+

+
    mod assert {
+
        use predicates::prelude::*;
+
        use predicates::str::ContainsPredicate;
+

+
        pub fn is_tui() -> ContainsPredicate {
+
            predicate::str::contains("Inappropriate ioctl for device")
+
        }
+

+
        pub fn is_rad_manual() -> ContainsPredicate {
+
            predicate::str::contains("rad-issue")
+
        }
+

+
        pub fn is_issue_help() -> ContainsPredicate {
+
            predicate::str::contains("Terminal interfaces for issues")
+
        }
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn empty_operation() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("issue");
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn empty_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("issue");
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn empty_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "--help"]);
+
        cmd.assert().success().stdout(assert::is_rad_manual());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn empty_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "--help", "--no-forward"]);
+
        cmd.assert().success().stdout(assert::is_issue_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn empty_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "--no-forward"]);
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn list_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "list"]);
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn list_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "list", "--no-forward"]);
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn list_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "list", "--help"]);
+
        cmd.assert().success().stdout(assert::is_rad_manual());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn list_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "list", "--help", "--no-forward"]);
+
        cmd.assert().success().stdout(assert::is_issue_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn list_operation_with_help_is_not_forwarded_reversed() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "list", "--no-forward", "--help"]);
+
        cmd.assert().success().stdout(assert::is_issue_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn unknown_operation_show_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "show"]);
+
        cmd.assert().success().stdout(predicate::str::contains(
+
            "Error: rad issue: an issue must be provided",
+
        ));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn unknown_operation_edit_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "edit"]);
+
        cmd.assert().success().stdout(predicate::str::contains(
+
            "Error: rad issue: an issue must be provided",
+
        ));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    #[ignore = "breaks stdout"]
+
    fn unknown_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["issue", "operation", "--no-forward"]);
+
        cmd.assert().success().stdout(predicate::str::contains(
+
            "Error: rad issue: unknown operation",
+
        ));
+

+
        Ok(())
+
    }
+
}
added bin/commands/issue/list.rs
@@ -0,0 +1,693 @@
+
#[path = "list/ui.rs"]
+
mod ui;
+

+
use std::collections::{HashMap, HashSet};
+
use std::str::FromStr;
+

+
use anyhow::{bail, Result};
+

+
use ratatui::Viewport;
+
use termion::event::Key;
+

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::Text;
+

+
use radicle::cob::thread::CommentId;
+
use radicle::git::Oid;
+
use radicle::issue::IssueId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
use radicle_tui as tui;
+

+
use tui::store;
+
use tui::ui::rm::widget::container::{
+
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
+
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
+
};
+
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::list::{Tree, TreeProps};
+
use tui::ui::rm::widget::window::{
+
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
+
};
+
use tui::ui::rm::widget::{PredefinedLayout, ToWidget, Widget};
+
use tui::ui::theme::Theme;
+
use tui::ui::Column;
+
use tui::ui::{span, BufferedValue};
+
use tui::{BoxedAny, Channel, Exit, PageStack};
+

+
use crate::cob::issue;
+
use crate::settings::{self, ThemeBundle, ThemeMode};
+
use crate::ui::items::{CommentItem, IssueItem, IssueItemFilter};
+
use crate::ui::rm::{BrowserState, IssueDetails, IssueDetailsProps};
+
use crate::ui::TerminalInfo;
+

+
use self::ui::{Browser, BrowserProps};
+

+
use super::common::{IssueOperation, Mode};
+

+
type Selection = tui::Selection<IssueId>;
+

+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub mode: Mode,
+
    pub filter: issue::Filter,
+
}
+

+
pub struct App {
+
    context: Context,
+
    terminal_info: TerminalInfo,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+
pub enum AppPage {
+
    Browser,
+
    Help,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Section {
+
    #[default]
+
    Browser,
+
    Details,
+
    Comment,
+
}
+

+
impl TryFrom<usize> for Section {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: usize) -> Result<Self, Self::Error> {
+
        match value {
+
            0 => Ok(Section::Browser),
+
            1 => Ok(Section::Details),
+
            2 => Ok(Section::Comment),
+
            _ => bail!("Unknown section index: {}", value),
+
        }
+
    }
+
}
+

+
impl From<Section> for usize {
+
    fn from(section: Section) -> Self {
+
        match section {
+
            Section::Browser => 0,
+
            Section::Details => 1,
+
            Section::Comment => 2,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct PreviewState {
+
    /// If preview is visible.
+
    show: bool,
+
    /// Currently selected issue item.
+
    issue: Option<IssueItem>,
+
    /// Tree selection per issue.
+
    selected_comments: HashMap<IssueId, Vec<CommentId>>,
+
    /// State of currently selected comment
+
    comment: TextViewState,
+
}
+

+
impl PreviewState {
+
    pub fn root_comments(&self) -> Vec<CommentItem> {
+
        self.issue
+
            .as_ref()
+
            .map(|item| item.root_comments())
+
            .unwrap_or_default()
+
    }
+

+
    pub fn selected_comment(&self) -> Option<&CommentItem> {
+
        self.issue.as_ref().and_then(|item| {
+
            self.selected_comments
+
                .get(&item.id)
+
                .and_then(|selection| selection.last().copied())
+
                .and_then(|comment_id| {
+
                    item.comments
+
                        .iter()
+
                        .filter(|item| item.id == comment_id)
+
                        .collect::<Vec<_>>()
+
                        .first()
+
                        .cloned()
+
                })
+
        })
+
    }
+

+
    pub fn selected_comment_ids(&self) -> Vec<String> {
+
        self.issue
+
            .as_ref()
+
            .and_then(|item| self.selected_comments.get(&item.id))
+
            .map(|selected| selected.iter().map(|oid| oid.to_string()).collect())
+
            .unwrap_or_default()
+
    }
+

+
    pub fn opened_comments(&self) -> HashSet<Vec<String>> {
+
        let mut opened = HashSet::new();
+
        if let Some(item) = &self.issue {
+
            for comment in item.root_comments() {
+
                append_opened(&mut opened, vec![], comment.clone());
+
            }
+
        }
+
        opened
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct HelpState {
+
    text: TextViewState,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct State {
+
    mode: Mode,
+
    pages: PageStack<AppPage>,
+
    browser: BrowserState<IssueItem, IssueItemFilter>,
+
    preview: PreviewState,
+
    section: Option<Section>,
+
    help: HelpState,
+
    theme: Theme,
+
}
+

+
impl TryFrom<(&Context, &TerminalInfo)> for State {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Context, &TerminalInfo)) -> Result<Self, Self::Error> {
+
        let (context, terminal_info) = value;
+
        let settings = settings::Settings::default();
+

+
        let issues = issue::all(&context.profile, &context.repository)?;
+
        let search = BufferedValue::new(context.filter.to_string());
+
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();
+

+
        let default_bundle = ThemeBundle::default();
+
        let theme_bundle = settings.theme.active_bundle().unwrap_or(&default_bundle);
+
        let theme = match settings.theme.mode() {
+
            ThemeMode::Auto => {
+
                if terminal_info.is_dark() {
+
                    theme_bundle.dark.clone()
+
                } else {
+
                    theme_bundle.light.clone()
+
                }
+
            }
+
            ThemeMode::Light => theme_bundle.light.clone(),
+
            ThemeMode::Dark => theme_bundle.dark.clone(),
+
        };
+

+
        // Convert into UI items
+
        let mut items = vec![];
+
        for issue in issues {
+
            if let Ok(item) = IssueItem::new(&context.profile, issue.clone()) {
+
                items.push(item);
+
            }
+
        }
+
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+

+
        // Pre-select first comment
+
        let mut selected_comments = HashMap::new();
+
        for item in &items {
+
            selected_comments.insert(
+
                item.id,
+
                item.root_comments()
+
                    .first()
+
                    .map(|comment| vec![comment.id])
+
                    .unwrap_or_default(),
+
            );
+
        }
+

+
        Ok(Self {
+
            mode: context.mode.clone(),
+
            pages: PageStack::new(vec![AppPage::Browser]),
+
            browser: BrowserState::build(items.clone(), filter, search),
+
            preview: PreviewState {
+
                show: true,
+
                issue: items.first().cloned(),
+
                selected_comments,
+
                comment: TextViewState::default(),
+
            },
+
            section: Some(Section::Browser),
+
            help: HelpState {
+
                text: TextViewState::default().content(help_text()),
+
            },
+
            theme,
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Quit,
+
    Exit { operation: Option<IssueOperation> },
+
    ExitFromMode,
+
    SelectIssue { selected: Option<usize> },
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
+
    TogglePreview,
+
    FocusSection { section: Option<Section> },
+
    SelectComment { selected: Option<Vec<CommentId>> },
+
    ScrollComment { state: TextViewState },
+
    OpenHelp,
+
    LeavePage,
+
    ScrollHelp { state: TextViewState },
+
}
+

+
impl store::Update<Message> for State {
+
    type Return = Selection;
+

+
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => self.browser.selected_item().map(|issue| Exit {
+
                value: Some(Selection {
+
                    operation: operation.map(|op| op.to_string()),
+
                    ids: vec![issue.id],
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::ExitFromMode => {
+
                let operation = match self.mode {
+
                    Mode::Operation => Some(IssueOperation::Show.to_string()),
+
                    Mode::Id => None,
+
                };
+

+
                self.browser.selected_item().map(|issue| Exit {
+
                    value: Some(Selection {
+
                        operation,
+
                        ids: vec![issue.id],
+
                        args: vec![],
+
                    }),
+
                })
+
            }
+
            Message::SelectIssue { selected } => {
+
                self.browser.select_item(selected);
+
                self.preview.issue = self.browser.selected_item().cloned();
+
                self.preview.comment.reset_cursor();
+
                None
+
            }
+
            Message::TogglePreview => {
+
                self.preview.show = !self.preview.show;
+
                self.section = Some(Section::Browser);
+
                None
+
            }
+
            Message::FocusSection { section } => {
+
                self.section = section;
+
                None
+
            }
+
            Message::SelectComment { selected } => {
+
                if let Some(item) = &self.preview.issue {
+
                    self.preview
+
                        .selected_comments
+
                        .insert(item.id, selected.unwrap_or(vec![]));
+
                }
+
                self.preview.comment.reset_cursor();
+
                None
+
            }
+
            Message::ScrollComment { state } => {
+
                self.preview.comment = state;
+
                None
+
            }
+
            Message::OpenSearch => {
+
                self.browser.show_search();
+
                None
+
            }
+
            Message::UpdateSearch { value } => {
+
                self.browser.update_search(value);
+
                self.preview.issue = self.browser.select_first_item().cloned();
+
                None
+
            }
+
            Message::ApplySearch => {
+
                self.browser.hide_search();
+
                self.browser.apply_search();
+
                None
+
            }
+
            Message::CloseSearch => {
+
                self.browser.hide_search();
+
                self.browser.reset_search();
+

+
                self.preview.issue = self.browser.selected_item().cloned();
+
                self.preview.comment.reset_cursor();
+
                None
+
            }
+
            Message::OpenHelp => {
+
                self.pages.push(AppPage::Help);
+
                None
+
            }
+
            Message::LeavePage => {
+
                self.pages.pop();
+
                None
+
            }
+
            Message::ScrollHelp { state } => {
+
                self.help.text = state;
+
                None
+
            }
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn new(context: Context, terminal_info: TerminalInfo) -> Self {
+
        Self {
+
            context,
+
            terminal_info,
+
        }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let channel = Channel::default();
+
        let state = State::try_from((&self.context, &self.terminal_info))?;
+
        let tx = channel.tx.clone();
+

+
        let window = Window::default()
+
            .page(AppPage::Browser, browser_page(&channel))
+
            .page(AppPage::Help, help_page(&channel))
+
            .to_widget(tx.clone())
+
            .on_update(|state| {
+
                WindowProps::default()
+
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browser).clone())
+
                    .to_boxed_any()
+
                    .into()
+
            });
+

+
        tui::rm(state, window, Viewport::Inline(20), channel).await
+
    }
+
}
+

+
fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let shortcuts = Shortcuts::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            let shortcuts = if state.browser.is_search_shown() {
+
                vec![("esc", "cancel"), ("enter", "apply")]
+
            } else {
+
                let mut shortcuts = match state.mode {
+
                    Mode::Id => vec![("enter", "select")],
+
                    Mode::Operation => vec![("enter", "show"), ("e", "edit")],
+
                };
+
                if state.section == Some(Section::Browser) {
+
                    shortcuts = [shortcuts, [("/", "search")].to_vec()].concat()
+
                }
+
                [shortcuts, [("p", "toggle preview"), ("?", "help")].to_vec()].concat()
+
            };
+

+
            ShortcutsProps::default()
+
                .shortcuts(&shortcuts)
+
                .shortcuts_keys_style(state.theme.shortcuts_keys_style)
+
                .shortcuts_action_style(state.theme.shortcuts_action_style)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    Page::default()
+
        .content(
+
            SectionGroup::default()
+
                .section(browser(channel))
+
                .section(issue(channel))
+
                .section(comment(channel))
+
                .to_widget(tx.clone())
+
                .on_event(|_, vs, _| {
+
                    Some(Message::FocusSection {
+
                        section: vs.and_then(|vs| {
+
                            vs.unwrap_section_group()
+
                                .and_then(|sgs| sgs.focus)
+
                                .map(|s| s.try_into().unwrap_or_default())
+
                        }),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    SectionGroupProps::default()
+
                        .handle_keys(state.preview.show && !state.browser.is_search_shown())
+
                        .layout(PredefinedLayout::Expandable3 {
+
                            left_only: !state.preview.show,
+
                        })
+
                        .focus(state.section.as_ref().map(|s| s.clone().into()))
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, props| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+

+
            if props.handle_keys {
+
                match key {
+
                    Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
+
                    Key::Char('p') => Some(Message::TogglePreview),
+
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    Key::Char('\n') => Some(Message::ExitFromMode),
+
                    Key::Char('e') => Some(Message::Exit {
+
                        operation: Some(IssueOperation::Edit),
+
                    }),
+
                    _ => None,
+
                }
+
            } else {
+
                None
+
            }
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .handle_keys(!state.browser.is_search_shown())
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn browser(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    Browser::new(tx.clone())
+
        .to_widget(tx.clone())
+
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into())
+
}
+

+
fn issue(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    SplitContainer::default()
+
        .top(issue_details(channel))
+
        .bottom(comment_tree(channel))
+
        .to_widget(tx.clone())
+
        .on_update(|state| {
+
            SplitContainerProps::default()
+
                .heights([Constraint::Length(5), Constraint::Min(1)])
+
                .border_style(state.theme.border_style)
+
                .focus_border_style(state.theme.focus_border_style)
+
                .split_focus(SplitContainerFocus::Bottom)
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn issue_details(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    IssueDetails::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            IssueDetailsProps::default()
+
                .issue(state.preview.issue.clone())
+
                .dim(state.theme.dim_no_focus)
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn comment_tree(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    Tree::<State, Message, CommentItem, String>::default()
+
        .to_widget(tx.clone())
+
        .on_event(|_, s, _| {
+
            Some(Message::SelectComment {
+
                selected: s.and_then(|s| {
+
                    s.unwrap_tree()
+
                        .map(|tree| tree.iter().map(|id| Oid::from_str(id).unwrap()).collect())
+
                }),
+
            })
+
        })
+
        .on_update(|state| {
+
            let root = &state.preview.root_comments();
+
            let opened = &state.preview.opened_comments();
+
            let selected = &state.preview.selected_comment_ids();
+

+
            TreeProps::<CommentItem, String>::default()
+
                .items(root.to_vec())
+
                .selected(Some(selected))
+
                .opened(Some(opened.clone()))
+
                .dim(state.theme.dim_no_focus)
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn comment(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    Container::default()
+
        .content(
+
            TextView::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, vs, _| {
+
                    let state = vs.and_then(|p| p.unwrap_textview()).unwrap_or_default();
+
                    Some(Message::ScrollComment { state })
+
                })
+
                .on_update(|state: &State| {
+
                    let comment = state.preview.selected_comment();
+
                    let body: String = comment
+
                        .map(|comment| comment.body.clone())
+
                        .unwrap_or_default();
+
                    let reactions = comment
+
                        .map(|comment| {
+
                            let reactions = comment.accumulated_reactions().iter().fold(
+
                                String::new(),
+
                                |all, (r, acc)| {
+
                                    if *acc > 1_usize {
+
                                        [all, format!("{}{} ", r, acc)].concat()
+
                                    } else {
+
                                        [all, format!("{} ", r)].concat()
+
                                    }
+
                                },
+
                            );
+
                            reactions
+
                        })
+
                        .unwrap_or_default();
+

+
                    TextViewProps::default()
+
                        .state(Some(state.preview.comment.clone().content(body)))
+
                        .footer(Some(reactions))
+
                        .show_scroll_progress(true)
+
                        .dim(state.theme.dim_no_focus)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone())
+
        .on_update(|state| {
+
            ContainerProps::default()
+
                .border_style(state.theme.border_style)
+
                .focus_border_style(state.theme.focus_border_style)
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Container::default()
+
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
+
            HeaderProps::default()
+
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
+
                .to_boxed_any()
+
                .into()
+
        }))
+
        .content(
+
            TextView::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, view_state, _| {
+
                    view_state
+
                        .and_then(|tv| tv.unwrap_textview())
+
                        .map(|tvs| Message::ScrollHelp { state: tvs })
+
                })
+
                .on_update(|state: &State| {
+
                    TextViewProps::default()
+
                        .state(Some(state.help.text.clone()))
+
                        .dim(state.theme.dim_no_focus)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .footer(
+
            Footer::default()
+
                .to_widget(tx.clone())
+
                .on_update(|state: &State| {
+
                    FooterProps::default()
+
                        .columns(
+
                            [
+
                                Column::new(Text::raw(""), Constraint::Fill(1)),
+
                                Column::new(
+
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
+
                                    Constraint::Min(4),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone())
+
        .on_update(|state| {
+
            ContainerProps::default()
+
                .border_style(state.theme.border_style)
+
                .focus_border_style(state.theme.focus_border_style)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
+
        ShortcutsProps::default()
+
            .shortcuts(&[("?", "close")])
+
            .to_boxed_any()
+
            .into()
+
    });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
+
            Key::Char('?') => Some(Message::LeavePage),
+
            _ => None,
+
        })
+
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
+
}
+

+
fn help_text() -> String {
+
    r#"# Generic keybindings
+

+
`↑,k`:      move cursor one line up
+
`↓,j:       move cursor one line down
+
`PageUp`:   move cursor one page up
+
`PageDown`: move cursor one page down
+
`Home`:     move cursor to the first line
+
`End`:      move cursor to the last line
+
`Tab`:      focus next section
+
`BackTab`:  focus previous section
+
`Esc`:      Quit / cancel
+

+
# Specific keybindings
+

+
`Enter`:    Select issue (if --mode id)
+
`Enter`:    Show issue
+
`e`:        Edit issue
+
`p`:        Toggle issue preview
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>
+
Example:    is:solved is:authored alias"#
+
        .into()
+
}
+

+
fn append_opened(all: &mut HashSet<Vec<String>>, path: Vec<String>, comment: CommentItem) {
+
    all.insert([path.clone(), [comment.id.to_string()].to_vec()].concat());
+

+
    for reply in comment.replies {
+
        append_opened(
+
            all,
+
            [path.clone(), [comment.id.to_string()].to_vec()].concat(),
+
            reply,
+
        );
+
    }
+
}
added bin/commands/issue/list/ui.rs
@@ -0,0 +1,326 @@
+
use std::collections::HashMap;
+
use std::str::FromStr;
+
use std::vec;
+

+
use radicle::issue::{self, CloseReason};
+
use ratatui::Frame;
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Text};
+

+
use radicle_tui as tui;
+

+
use tui::ui::rm::widget;
+
use tui::ui::rm::widget::container::{
+
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
};
+
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
+
use tui::ui::rm::widget::list::{Table, TableProps};
+
use tui::ui::rm::widget::ViewProps;
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::span;
+
use tui::ui::Column;
+

+
use tui::BoxedAny;
+

+
use crate::ui::items::{IssueItem, IssueItemFilter};
+

+
use super::{Message, State};
+

+
type Widget = widget::Widget<State, Message>;
+

+
#[derive(Clone, Default)]
+
pub struct BrowserProps<'a> {
+
    /// Filtered issues.
+
    issues: Vec<IssueItem>,
+
    /// Issue statistics.
+
    stats: HashMap<String, usize>,
+
    /// Header columns
+
    header: Vec<Column<'a>>,
+
    /// Table columns
+
    columns: Vec<Column<'a>>,
+
    /// If search widget should be shown.
+
    show_search: bool,
+
    /// Current search string.
+
    search: String,
+
}
+

+
impl<'a> From<&State> for BrowserProps<'a> {
+
    fn from(state: &State) -> Self {
+
        use radicle::issue::State;
+

+
        let issues = state.browser.items();
+

+
        let mut open = 0;
+
        let mut other = 0;
+
        let mut solved = 0;
+

+
        for issue in &issues {
+
            match issue.state {
+
                State::Open => open += 1,
+
                State::Closed {
+
                    reason: CloseReason::Other,
+
                } => other += 1,
+
                State::Closed {
+
                    reason: CloseReason::Solved,
+
                } => solved += 1,
+
            }
+
        }
+

+
        let closed = solved + other;
+

+
        let stats = HashMap::from([
+
            ("Open".to_string(), open),
+
            ("Other".to_string(), other),
+
            ("Solved".to_string(), solved),
+
            ("Closed".to_string(), closed),
+
        ]);
+

+
        Self {
+
            issues,
+
            stats,
+
            header: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(5)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Opened", Constraint::Length(16)).hide_small(),
+
            ]
+
            .to_vec(),
+
            columns: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(5)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Opened", Constraint::Length(16)).hide_small(),
+
            ]
+
            .to_vec(),
+
            search: state.browser.read_search(),
+
            show_search: state.browser.is_search_shown(),
+
        }
+
    }
+
}
+

+
pub struct Browser {
+
    /// Notifications widget
+
    issues: Widget,
+
    /// Search widget
+
    search: Widget,
+
}
+

+
impl Browser {
+
    pub fn new(tx: UnboundedSender<Message>) -> Self {
+
        Self {
+
            issues: Container::default()
+
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+

+
                    HeaderProps::default()
+
                        .columns(props.header.clone())
+
                        .border_style(state.theme.border_style)
+
                        .focus_border_style(state.theme.focus_border_style)
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(
+
                    Table::<State, Message, IssueItem, 8>::default()
+
                        .to_widget(tx.clone())
+
                        .on_event(|_, s, _| {
+
                            let (selected, _) =
+
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
+
                            Some(Message::SelectIssue {
+
                                selected: Some(selected),
+
                            })
+
                        })
+
                        .on_update(|state| {
+
                            let props = BrowserProps::from(state);
+

+
                            TableProps::default()
+
                                .columns(props.columns)
+
                                .items(state.browser.items())
+
                                .selected(state.browser.selected())
+
                                .dim(state.theme.dim_no_focus)
+
                                .to_boxed_any()
+
                                .into()
+
                        }),
+
                )
+
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
+
                    let props = BrowserProps::from(state);
+

+
                    FooterProps::default()
+
                        .columns(browse_footer(&props))
+
                        .border_style(state.theme.border_style)
+
                        .focus_border_style(state.theme.focus_border_style)
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .to_widget(tx.clone())
+
                .on_update(|state| {
+
                    ContainerProps::default()
+
                        .border_style(state.theme.border_style)
+
                        .focus_border_style(state.theme.focus_border_style)
+
                        .hide_footer(BrowserProps::from(state).show_search)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
            search: TextField::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, s, _| {
+
                    Some(Message::UpdateSearch {
+
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    TextFieldProps::default()
+
                        .text(&state.browser.read_search())
+
                        .title("Search")
+
                        .inline(true)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        }
+
    }
+
}
+

+
impl View for Browser {
+
    type Message = Message;
+
    type State = State;
+

+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
+
            match key {
+
                Key::Esc => {
+
                    self.search.reset();
+
                    Some(Message::CloseSearch)
+
                }
+
                Key::Char('\n') => Some(Message::ApplySearch),
+
                _ => {
+
                    self.search.handle_event(key);
+
                    None
+
                }
+
            }
+
        } else {
+
            match key {
+
                Key::Char('/') => Some(Message::OpenSearch),
+
                _ => {
+
                    self.issues.handle_event(key);
+
                    None
+
                }
+
            }
+
        }
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        self.issues.update(state);
+
        self.search.update(state);
+
    }
+

+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
+
            let [_, search_area, _] = Layout::horizontal([
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
                Constraint::Length(1),
+
            ])
+
            .areas(search_area);
+

+
            self.issues.render(RenderProps::from(table_area), frame);
+
            self.search
+
                .render(RenderProps::from(search_area).focus(render.focus), frame);
+
        } else {
+
            self.issues.render(render, frame);
+
        }
+
    }
+
}
+

+
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
+
    let search = Line::from(vec![
+
        span::default(" Search ").cyan().dim().reversed(),
+
        span::default(" "),
+
        span::default(&props.search).gray().dim(),
+
    ]);
+

+
    let open = Line::from(vec![
+
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
        span::default(" Open").dim(),
+
    ]);
+
    let solved = Line::from(vec![
+
        span::default(&props.stats.get("Solved").unwrap_or(&0).to_string())
+
            .magenta()
+
            .dim(),
+
        span::default(" Solved").dim(),
+
    ]);
+
    let closed = Line::from(vec![
+
        span::default(&props.stats.get("Closed").unwrap_or(&0).to_string())
+
            .magenta()
+
            .dim(),
+
        span::default(" Closed").dim(),
+
    ]);
+
    let sum = Line::from(vec![
+
        span::default("Σ ").dim(),
+
        span::default(&props.issues.len().to_string()).dim(),
+
    ]);
+

+
    match IssueItemFilter::from_str(&props.search)
+
        .unwrap_or_default()
+
        .state()
+
    {
+
        Some(state) => {
+
            let block = match state {
+
                issue::State::Open => open,
+
                issue::State::Closed {
+
                    reason: issue::CloseReason::Other,
+
                } => closed,
+
                issue::State::Closed {
+
                    reason: issue::CloseReason::Solved,
+
                } => solved,
+
            };
+

+
            [
+
                Column::new(Text::from(search), Constraint::Fill(1)),
+
                Column::new(
+
                    Text::from(block.clone()),
+
                    Constraint::Min(block.width() as u16),
+
                ),
+
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
            ]
+
            .to_vec()
+
        }
+
        None => [
+
            Column::new(Text::from(search), Constraint::Fill(1)),
+
            Column::new(
+
                Text::from(open.clone()),
+
                Constraint::Min(open.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(closed.clone()),
+
                Constraint::Min(closed.width() as u16),
+
            ),
+
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
        ]
+
        .to_vec(),
+
    }
+
}
deleted bin/commands/issue/select.rs
@@ -1,693 +0,0 @@
-
#[path = "select/ui.rs"]
-
mod ui;
-

-
use std::collections::{HashMap, HashSet};
-
use std::str::FromStr;
-

-
use anyhow::{bail, Result};
-

-
use ratatui::Viewport;
-
use termion::event::Key;
-

-
use ratatui::layout::Constraint;
-
use ratatui::style::Stylize;
-
use ratatui::text::Text;
-

-
use radicle::cob::thread::CommentId;
-
use radicle::git::Oid;
-
use radicle::issue::IssueId;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

-
use radicle_tui as tui;
-

-
use tui::store;
-
use tui::ui::rm::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
-
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
-
};
-
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::rm::widget::list::{Tree, TreeProps};
-
use tui::ui::rm::widget::window::{
-
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
-
};
-
use tui::ui::rm::widget::{PredefinedLayout, ToWidget, Widget};
-
use tui::ui::theme::Theme;
-
use tui::ui::Column;
-
use tui::ui::{span, BufferedValue};
-
use tui::{BoxedAny, Channel, Exit, PageStack};
-

-
use crate::cob::issue;
-
use crate::settings::{self, ThemeBundle, ThemeMode};
-
use crate::ui::items::{CommentItem, IssueItem, IssueItemFilter};
-
use crate::ui::rm::{BrowserState, IssueDetails, IssueDetailsProps};
-
use crate::ui::TerminalInfo;
-

-
use self::ui::{Browser, BrowserProps};
-

-
use super::common::{IssueOperation, Mode};
-

-
type Selection = tui::Selection<IssueId>;
-

-
pub struct Context {
-
    pub profile: Profile,
-
    pub repository: Repository,
-
    pub mode: Mode,
-
    pub filter: issue::Filter,
-
}
-

-
pub struct App {
-
    context: Context,
-
    terminal_info: TerminalInfo,
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum AppPage {
-
    Browser,
-
    Help,
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Section {
-
    #[default]
-
    Browser,
-
    Details,
-
    Comment,
-
}
-

-
impl TryFrom<usize> for Section {
-
    type Error = anyhow::Error;
-

-
    fn try_from(value: usize) -> Result<Self, Self::Error> {
-
        match value {
-
            0 => Ok(Section::Browser),
-
            1 => Ok(Section::Details),
-
            2 => Ok(Section::Comment),
-
            _ => bail!("Unknown section index: {}", value),
-
        }
-
    }
-
}
-

-
impl From<Section> for usize {
-
    fn from(section: Section) -> Self {
-
        match section {
-
            Section::Browser => 0,
-
            Section::Details => 1,
-
            Section::Comment => 2,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct PreviewState {
-
    /// If preview is visible.
-
    show: bool,
-
    /// Currently selected issue item.
-
    issue: Option<IssueItem>,
-
    /// Tree selection per issue.
-
    selected_comments: HashMap<IssueId, Vec<CommentId>>,
-
    /// State of currently selected comment
-
    comment: TextViewState,
-
}
-

-
impl PreviewState {
-
    pub fn root_comments(&self) -> Vec<CommentItem> {
-
        self.issue
-
            .as_ref()
-
            .map(|item| item.root_comments())
-
            .unwrap_or_default()
-
    }
-

-
    pub fn selected_comment(&self) -> Option<&CommentItem> {
-
        self.issue.as_ref().and_then(|item| {
-
            self.selected_comments
-
                .get(&item.id)
-
                .and_then(|selection| selection.last().copied())
-
                .and_then(|comment_id| {
-
                    item.comments
-
                        .iter()
-
                        .filter(|item| item.id == comment_id)
-
                        .collect::<Vec<_>>()
-
                        .first()
-
                        .cloned()
-
                })
-
        })
-
    }
-

-
    pub fn selected_comment_ids(&self) -> Vec<String> {
-
        self.issue
-
            .as_ref()
-
            .and_then(|item| self.selected_comments.get(&item.id))
-
            .map(|selected| selected.iter().map(|oid| oid.to_string()).collect())
-
            .unwrap_or_default()
-
    }
-

-
    pub fn opened_comments(&self) -> HashSet<Vec<String>> {
-
        let mut opened = HashSet::new();
-
        if let Some(item) = &self.issue {
-
            for comment in item.root_comments() {
-
                append_opened(&mut opened, vec![], comment.clone());
-
            }
-
        }
-
        opened
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct HelpState {
-
    text: TextViewState,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct State {
-
    mode: Mode,
-
    pages: PageStack<AppPage>,
-
    browser: BrowserState<IssueItem, IssueItemFilter>,
-
    preview: PreviewState,
-
    section: Option<Section>,
-
    help: HelpState,
-
    theme: Theme,
-
}
-

-
impl TryFrom<(&Context, &TerminalInfo)> for State {
-
    type Error = anyhow::Error;
-

-
    fn try_from(value: (&Context, &TerminalInfo)) -> Result<Self, Self::Error> {
-
        let (context, terminal_info) = value;
-
        let settings = settings::Settings::default();
-

-
        let issues = issue::all(&context.profile, &context.repository)?;
-
        let search = BufferedValue::new(context.filter.to_string());
-
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();
-

-
        let default_bundle = ThemeBundle::default();
-
        let theme_bundle = settings.theme.active_bundle().unwrap_or(&default_bundle);
-
        let theme = match settings.theme.mode() {
-
            ThemeMode::Auto => {
-
                if terminal_info.is_dark() {
-
                    theme_bundle.dark.clone()
-
                } else {
-
                    theme_bundle.light.clone()
-
                }
-
            }
-
            ThemeMode::Light => theme_bundle.light.clone(),
-
            ThemeMode::Dark => theme_bundle.dark.clone(),
-
        };
-

-
        // Convert into UI items
-
        let mut items = vec![];
-
        for issue in issues {
-
            if let Ok(item) = IssueItem::new(&context.profile, issue.clone()) {
-
                items.push(item);
-
            }
-
        }
-
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
-

-
        // Pre-select first comment
-
        let mut selected_comments = HashMap::new();
-
        for item in &items {
-
            selected_comments.insert(
-
                item.id,
-
                item.root_comments()
-
                    .first()
-
                    .map(|comment| vec![comment.id])
-
                    .unwrap_or_default(),
-
            );
-
        }
-

-
        Ok(Self {
-
            mode: context.mode.clone(),
-
            pages: PageStack::new(vec![AppPage::Browser]),
-
            browser: BrowserState::build(items.clone(), filter, search),
-
            preview: PreviewState {
-
                show: true,
-
                issue: items.first().cloned(),
-
                selected_comments,
-
                comment: TextViewState::default(),
-
            },
-
            section: Some(Section::Browser),
-
            help: HelpState {
-
                text: TextViewState::default().content(help_text()),
-
            },
-
            theme,
-
        })
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Quit,
-
    Exit { operation: Option<IssueOperation> },
-
    ExitFromMode,
-
    SelectIssue { selected: Option<usize> },
-
    OpenSearch,
-
    UpdateSearch { value: String },
-
    ApplySearch,
-
    CloseSearch,
-
    TogglePreview,
-
    FocusSection { section: Option<Section> },
-
    SelectComment { selected: Option<Vec<CommentId>> },
-
    ScrollComment { state: TextViewState },
-
    OpenHelp,
-
    LeavePage,
-
    ScrollHelp { state: TextViewState },
-
}
-

-
impl store::Update<Message> for State {
-
    type Return = Selection;
-

-
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
-
        match message {
-
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.browser.selected_item().map(|issue| Exit {
-
                value: Some(Selection {
-
                    operation: operation.map(|op| op.to_string()),
-
                    ids: vec![issue.id],
-
                    args: vec![],
-
                }),
-
            }),
-
            Message::ExitFromMode => {
-
                let operation = match self.mode {
-
                    Mode::Operation => Some(IssueOperation::Show.to_string()),
-
                    Mode::Id => None,
-
                };
-

-
                self.browser.selected_item().map(|issue| Exit {
-
                    value: Some(Selection {
-
                        operation,
-
                        ids: vec![issue.id],
-
                        args: vec![],
-
                    }),
-
                })
-
            }
-
            Message::SelectIssue { selected } => {
-
                self.browser.select_item(selected);
-
                self.preview.issue = self.browser.selected_item().cloned();
-
                self.preview.comment.reset_cursor();
-
                None
-
            }
-
            Message::TogglePreview => {
-
                self.preview.show = !self.preview.show;
-
                self.section = Some(Section::Browser);
-
                None
-
            }
-
            Message::FocusSection { section } => {
-
                self.section = section;
-
                None
-
            }
-
            Message::SelectComment { selected } => {
-
                if let Some(item) = &self.preview.issue {
-
                    self.preview
-
                        .selected_comments
-
                        .insert(item.id, selected.unwrap_or(vec![]));
-
                }
-
                self.preview.comment.reset_cursor();
-
                None
-
            }
-
            Message::ScrollComment { state } => {
-
                self.preview.comment = state;
-
                None
-
            }
-
            Message::OpenSearch => {
-
                self.browser.show_search();
-
                None
-
            }
-
            Message::UpdateSearch { value } => {
-
                self.browser.update_search(value);
-
                self.preview.issue = self.browser.select_first_item().cloned();
-
                None
-
            }
-
            Message::ApplySearch => {
-
                self.browser.hide_search();
-
                self.browser.apply_search();
-
                None
-
            }
-
            Message::CloseSearch => {
-
                self.browser.hide_search();
-
                self.browser.reset_search();
-

-
                self.preview.issue = self.browser.selected_item().cloned();
-
                self.preview.comment.reset_cursor();
-
                None
-
            }
-
            Message::OpenHelp => {
-
                self.pages.push(AppPage::Help);
-
                None
-
            }
-
            Message::LeavePage => {
-
                self.pages.pop();
-
                None
-
            }
-
            Message::ScrollHelp { state } => {
-
                self.help.text = state;
-
                None
-
            }
-
        }
-
    }
-
}
-

-
impl App {
-
    pub fn new(context: Context, terminal_info: TerminalInfo) -> Self {
-
        Self {
-
            context,
-
            terminal_info,
-
        }
-
    }
-

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let channel = Channel::default();
-
        let state = State::try_from((&self.context, &self.terminal_info))?;
-
        let tx = channel.tx.clone();
-

-
        let window = Window::default()
-
            .page(AppPage::Browser, browser_page(&channel))
-
            .page(AppPage::Help, help_page(&channel))
-
            .to_widget(tx.clone())
-
            .on_update(|state| {
-
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browser).clone())
-
                    .to_boxed_any()
-
                    .into()
-
            });
-

-
        tui::rm(state, window, Viewport::Inline(20), channel).await
-
    }
-
}
-

-
fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let shortcuts = Shortcuts::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state: &State| {
-
            let shortcuts = if state.browser.is_search_shown() {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                let mut shortcuts = match state.mode {
-
                    Mode::Id => vec![("enter", "select")],
-
                    Mode::Operation => vec![("enter", "show"), ("e", "edit")],
-
                };
-
                if state.section == Some(Section::Browser) {
-
                    shortcuts = [shortcuts, [("/", "search")].to_vec()].concat()
-
                }
-
                [shortcuts, [("p", "toggle preview"), ("?", "help")].to_vec()].concat()
-
            };
-

-
            ShortcutsProps::default()
-
                .shortcuts(&shortcuts)
-
                .shortcuts_keys_style(state.theme.shortcuts_keys_style)
-
                .shortcuts_action_style(state.theme.shortcuts_action_style)
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    Page::default()
-
        .content(
-
            SectionGroup::default()
-
                .section(browser(channel))
-
                .section(issue(channel))
-
                .section(comment(channel))
-
                .to_widget(tx.clone())
-
                .on_event(|_, vs, _| {
-
                    Some(Message::FocusSection {
-
                        section: vs.and_then(|vs| {
-
                            vs.unwrap_section_group()
-
                                .and_then(|sgs| sgs.focus)
-
                                .map(|s| s.try_into().unwrap_or_default())
-
                        }),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    SectionGroupProps::default()
-
                        .handle_keys(state.preview.show && !state.browser.is_search_shown())
-
                        .layout(PredefinedLayout::Expandable3 {
-
                            left_only: !state.preview.show,
-
                        })
-
                        .focus(state.section.as_ref().map(|s| s.clone().into()))
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, props| {
-
            let default = PageProps::default();
-
            let props = props
-
                .and_then(|props| props.inner_ref::<PageProps>())
-
                .unwrap_or(&default);
-

-
            if props.handle_keys {
-
                match key {
-
                    Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
-
                    Key::Char('p') => Some(Message::TogglePreview),
-
                    Key::Char('?') => Some(Message::OpenHelp),
-
                    Key::Char('\n') => Some(Message::ExitFromMode),
-
                    Key::Char('e') => Some(Message::Exit {
-
                        operation: Some(IssueOperation::Edit),
-
                    }),
-
                    _ => None,
-
                }
-
            } else {
-
                None
-
            }
-
        })
-
        .on_update(|state: &State| {
-
            PageProps::default()
-
                .handle_keys(!state.browser.is_search_shown())
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

-
fn browser(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    Browser::new(tx.clone())
-
        .to_widget(tx.clone())
-
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into())
-
}
-

-
fn issue(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    SplitContainer::default()
-
        .top(issue_details(channel))
-
        .bottom(comment_tree(channel))
-
        .to_widget(tx.clone())
-
        .on_update(|state| {
-
            SplitContainerProps::default()
-
                .heights([Constraint::Length(5), Constraint::Min(1)])
-
                .border_style(state.theme.border_style)
-
                .focus_border_style(state.theme.focus_border_style)
-
                .split_focus(SplitContainerFocus::Bottom)
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

-
fn issue_details(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    IssueDetails::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state: &State| {
-
            IssueDetailsProps::default()
-
                .issue(state.preview.issue.clone())
-
                .dim(state.theme.dim_no_focus)
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

-
fn comment_tree(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    Tree::<State, Message, CommentItem, String>::default()
-
        .to_widget(tx.clone())
-
        .on_event(|_, s, _| {
-
            Some(Message::SelectComment {
-
                selected: s.and_then(|s| {
-
                    s.unwrap_tree()
-
                        .map(|tree| tree.iter().map(|id| Oid::from_str(id).unwrap()).collect())
-
                }),
-
            })
-
        })
-
        .on_update(|state| {
-
            let root = &state.preview.root_comments();
-
            let opened = &state.preview.opened_comments();
-
            let selected = &state.preview.selected_comment_ids();
-

-
            TreeProps::<CommentItem, String>::default()
-
                .items(root.to_vec())
-
                .selected(Some(selected))
-
                .opened(Some(opened.clone()))
-
                .dim(state.theme.dim_no_focus)
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

-
fn comment(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    Container::default()
-
        .content(
-
            TextView::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, vs, _| {
-
                    let state = vs.and_then(|p| p.unwrap_textview()).unwrap_or_default();
-
                    Some(Message::ScrollComment { state })
-
                })
-
                .on_update(|state: &State| {
-
                    let comment = state.preview.selected_comment();
-
                    let body: String = comment
-
                        .map(|comment| comment.body.clone())
-
                        .unwrap_or_default();
-
                    let reactions = comment
-
                        .map(|comment| {
-
                            let reactions = comment.accumulated_reactions().iter().fold(
-
                                String::new(),
-
                                |all, (r, acc)| {
-
                                    if *acc > 1_usize {
-
                                        [all, format!("{}{} ", r, acc)].concat()
-
                                    } else {
-
                                        [all, format!("{} ", r)].concat()
-
                                    }
-
                                },
-
                            );
-
                            reactions
-
                        })
-
                        .unwrap_or_default();
-

-
                    TextViewProps::default()
-
                        .state(Some(state.preview.comment.clone().content(body)))
-
                        .footer(Some(reactions))
-
                        .show_scroll_progress(true)
-
                        .dim(state.theme.dim_no_focus)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone())
-
        .on_update(|state| {
-
            ContainerProps::default()
-
                .border_style(state.theme.border_style)
-
                .focus_border_style(state.theme.focus_border_style)
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

-
fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Container::default()
-
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
-
            HeaderProps::default()
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .to_boxed_any()
-
                .into()
-
        }))
-
        .content(
-
            TextView::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, view_state, _| {
-
                    view_state
-
                        .and_then(|tv| tv.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp { state: tvs })
-
                })
-
                .on_update(|state: &State| {
-
                    TextViewProps::default()
-
                        .state(Some(state.help.text.clone()))
-
                        .dim(state.theme.dim_no_focus)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .footer(
-
            Footer::default()
-
                .to_widget(tx.clone())
-
                .on_update(|state: &State| {
-
                    FooterProps::default()
-
                        .columns(
-
                            [
-
                                Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
-
                                    Constraint::Min(4),
-
                                ),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone())
-
        .on_update(|state| {
-
            ContainerProps::default()
-
                .border_style(state.theme.border_style)
-
                .focus_border_style(state.theme.focus_border_style)
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
-
        ShortcutsProps::default()
-
            .shortcuts(&[("?", "close")])
-
            .to_boxed_any()
-
            .into()
-
    });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, _| match key {
-
            Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
-
            Key::Char('?') => Some(Message::LeavePage),
-
            _ => None,
-
        })
-
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
-
}
-

-
fn help_text() -> String {
-
    r#"# Generic keybindings
-

-
`↑,k`:      move cursor one line up
-
`↓,j:       move cursor one line down
-
`PageUp`:   move cursor one page up
-
`PageDown`: move cursor one page down
-
`Home`:     move cursor to the first line
-
`End`:      move cursor to the last line
-
`Tab`:      focus next section
-
`BackTab`:  focus previous section
-
`Esc`:      Quit / cancel
-

-
# Specific keybindings
-

-
`Enter`:    Select issue (if --mode id)
-
`Enter`:    Show issue
-
`e`:        Edit issue
-
`p`:        Toggle issue preview
-
`/`:        Search
-
`?`:        Show help
-

-
# Searching
-

-
Pattern:    is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>
-
Example:    is:solved is:authored alias"#
-
        .into()
-
}
-

-
fn append_opened(all: &mut HashSet<Vec<String>>, path: Vec<String>, comment: CommentItem) {
-
    all.insert([path.clone(), [comment.id.to_string()].to_vec()].concat());
-

-
    for reply in comment.replies {
-
        append_opened(
-
            all,
-
            [path.clone(), [comment.id.to_string()].to_vec()].concat(),
-
            reply,
-
        );
-
    }
-
}
deleted bin/commands/issue/select/ui.rs
@@ -1,326 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-
use std::vec;
-

-
use radicle::issue::{self, CloseReason};
-
use ratatui::Frame;
-
use tokio::sync::mpsc::UnboundedSender;
-

-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Text};
-

-
use radicle_tui as tui;
-

-
use tui::ui::rm::widget;
-
use tui::ui::rm::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
-
};
-
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
-
use tui::ui::rm::widget::list::{Table, TableProps};
-
use tui::ui::rm::widget::ViewProps;
-
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
-
use tui::ui::span;
-
use tui::ui::Column;
-

-
use tui::BoxedAny;
-

-
use crate::ui::items::{IssueItem, IssueItemFilter};
-

-
use super::{Message, State};
-

-
type Widget = widget::Widget<State, Message>;
-

-
#[derive(Clone, Default)]
-
pub struct BrowserProps<'a> {
-
    /// Filtered issues.
-
    issues: Vec<IssueItem>,
-
    /// Issue statistics.
-
    stats: HashMap<String, usize>,
-
    /// Header columns
-
    header: Vec<Column<'a>>,
-
    /// Table columns
-
    columns: Vec<Column<'a>>,
-
    /// If search widget should be shown.
-
    show_search: bool,
-
    /// Current search string.
-
    search: String,
-
}
-

-
impl<'a> From<&State> for BrowserProps<'a> {
-
    fn from(state: &State) -> Self {
-
        use radicle::issue::State;
-

-
        let issues = state.browser.items();
-

-
        let mut open = 0;
-
        let mut other = 0;
-
        let mut solved = 0;
-

-
        for issue in &issues {
-
            match issue.state {
-
                State::Open => open += 1,
-
                State::Closed {
-
                    reason: CloseReason::Other,
-
                } => other += 1,
-
                State::Closed {
-
                    reason: CloseReason::Solved,
-
                } => solved += 1,
-
            }
-
        }
-

-
        let closed = solved + other;
-

-
        let stats = HashMap::from([
-
            ("Open".to_string(), open),
-
            ("Other".to_string(), other),
-
            ("Solved".to_string(), solved),
-
            ("Closed".to_string(), closed),
-
        ]);
-

-
        Self {
-
            issues,
-
            stats,
-
            header: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(5)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Opened", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            columns: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(5)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Opened", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            search: state.browser.read_search(),
-
            show_search: state.browser.is_search_shown(),
-
        }
-
    }
-
}
-

-
pub struct Browser {
-
    /// Notifications widget
-
    issues: Widget,
-
    /// Search widget
-
    search: Widget,
-
}
-

-
impl Browser {
-
    pub fn new(tx: UnboundedSender<Message>) -> Self {
-
        Self {
-
            issues: Container::default()
-
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-

-
                    HeaderProps::default()
-
                        .columns(props.header.clone())
-
                        .border_style(state.theme.border_style)
-
                        .focus_border_style(state.theme.focus_border_style)
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(
-
                    Table::<State, Message, IssueItem, 8>::default()
-
                        .to_widget(tx.clone())
-
                        .on_event(|_, s, _| {
-
                            let (selected, _) =
-
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::SelectIssue {
-
                                selected: Some(selected),
-
                            })
-
                        })
-
                        .on_update(|state| {
-
                            let props = BrowserProps::from(state);
-

-
                            TableProps::default()
-
                                .columns(props.columns)
-
                                .items(state.browser.items())
-
                                .selected(state.browser.selected())
-
                                .dim(state.theme.dim_no_focus)
-
                                .to_boxed_any()
-
                                .into()
-
                        }),
-
                )
-
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
-
                    let props = BrowserProps::from(state);
-

-
                    FooterProps::default()
-
                        .columns(browse_footer(&props))
-
                        .border_style(state.theme.border_style)
-
                        .focus_border_style(state.theme.focus_border_style)
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    ContainerProps::default()
-
                        .border_style(state.theme.border_style)
-
                        .focus_border_style(state.theme.focus_border_style)
-
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
            search: TextField::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    Some(Message::UpdateSearch {
-
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.read_search())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        }
-
    }
-
}
-

-
impl View for Browser {
-
    type Message = Message;
-
    type State = State;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            match key {
-
                Key::Esc => {
-
                    self.search.reset();
-
                    Some(Message::CloseSearch)
-
                }
-
                Key::Char('\n') => Some(Message::ApplySearch),
-
                _ => {
-
                    self.search.handle_event(key);
-
                    None
-
                }
-
            }
-
        } else {
-
            match key {
-
                Key::Char('/') => Some(Message::OpenSearch),
-
                _ => {
-
                    self.issues.handle_event(key);
-
                    None
-
                }
-
            }
-
        }
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        self.issues.update(state);
-
        self.search.update(state);
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
-
            let [_, search_area, _] = Layout::horizontal([
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
                Constraint::Length(1),
-
            ])
-
            .areas(search_area);
-

-
            self.issues.render(RenderProps::from(table_area), frame);
-
            self.search
-
                .render(RenderProps::from(search_area).focus(render.focus), frame);
-
        } else {
-
            self.issues.render(render, frame);
-
        }
-
    }
-
}
-

-
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
-
    let search = Line::from(vec![
-
        span::default(" Search ").cyan().dim().reversed(),
-
        span::default(" "),
-
        span::default(&props.search).gray().dim(),
-
    ]);
-

-
    let open = Line::from(vec![
-
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Open").dim(),
-
    ]);
-
    let solved = Line::from(vec![
-
        span::default(&props.stats.get("Solved").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Solved").dim(),
-
    ]);
-
    let closed = Line::from(vec![
-
        span::default(&props.stats.get("Closed").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Closed").dim(),
-
    ]);
-
    let sum = Line::from(vec![
-
        span::default("Σ ").dim(),
-
        span::default(&props.issues.len().to_string()).dim(),
-
    ]);
-

-
    match IssueItemFilter::from_str(&props.search)
-
        .unwrap_or_default()
-
        .state()
-
    {
-
        Some(state) => {
-
            let block = match state {
-
                issue::State::Open => open,
-
                issue::State::Closed {
-
                    reason: issue::CloseReason::Other,
-
                } => closed,
-
                issue::State::Closed {
-
                    reason: issue::CloseReason::Solved,
-
                } => solved,
-
            };
-

-
            [
-
                Column::new(Text::from(search), Constraint::Fill(1)),
-
                Column::new(
-
                    Text::from(block.clone()),
-
                    Constraint::Min(block.width() as u16),
-
                ),
-
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
            ]
-
            .to_vec()
-
        }
-
        None => [
-
            Column::new(Text::from(search), Constraint::Fill(1)),
-
            Column::new(
-
                Text::from(open.clone()),
-
                Constraint::Min(open.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(closed.clone()),
-
                Constraint::Min(closed.width() as u16),
-
            ),
-
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
        ]
-
        .to_vec(),
-
    }
-
}
modified bin/commands/patch.rs
@@ -1,9 +1,9 @@
#[path = "patch/common.rs"]
mod common;
+
#[path = "patch/list.rs"]
+
mod list;
#[path = "patch/review.rs"]
mod review;
-
#[path = "patch/select.rs"]
-
mod select;

use std::ffi::OsString;

@@ -28,11 +28,13 @@ pub const HELP: Help = Help {
    usage: r#"
Usage

-
    rad-tui patch select [<option>...]
+
    rad-tui patch list [<option>...]

-
Select options
+
List options

    --mode <MODE>           Set selection mode; see MODE below (default: operation)
+
    --json                  Return JSON on stderr instead of calling `rad`
+

    --all                   Show all patches, including merged and archived patches
    --archived              Show only archived patches
    --merged                Show only merged patches
@@ -48,7 +50,8 @@ Select options

Other options

-
    --help              Print help
+
    --no-forward        Don't forward command to `rad` (default: true)
+
    --help              Print help (enables forwarding)
"#,
};

@@ -58,20 +61,24 @@ pub struct Options {
}

pub enum Operation {
-
    Select { opts: SelectOptions },
+
    List { opts: ListOptions },
    Review { opts: ReviewOptions },
+
    Other { args: Vec<OsString> },
}

+
#[allow(dead_code)]
#[derive(PartialEq, Eq)]
pub enum OperationName {
-
    Select,
+
    List,
    Review,
+
    Other,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub struct SelectOptions {
+
pub struct ListOptions {
    mode: common::Mode,
    filter: patch::Filter,
+
    json: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
@@ -109,50 +116,64 @@ impl Args for Options {
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
        use lexopt::prelude::*;

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
+
        let mut parser = lexopt::Parser::from_args(args.clone());
+
        let mut op = OperationName::List;
+
        let mut forward = None;
+
        let mut json = false;
+
        let mut help = false;
        let mut repo = None;
-
        let mut select_opts = SelectOptions::default();
+
        let mut list_opts = ListOptions::default();
        let mut patch_id = None;
        let mut revision_id = None;

        while let Some(arg) = parser.next()? {
            match arg {
+
                Long("no-forward") => {
+
                    forward = Some(false);
+
                }
+
                Long("json") => {
+
                    json = true;
+
                }
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
+
                    help = true;
+
                    // Only enable forwarding if it was not already disabled explicitly
+
                    forward = match forward {
+
                        Some(false) => Some(false),
+
                        _ => Some(true),
+
                    };
                }

                // select options.
-
                Long("mode") | Short('m') if op == Some(OperationName::Select) => {
+
                Long("mode") | Short('m') if op == OperationName::List => {
                    let val = parser.value()?;
                    let val = val.to_str().unwrap_or_default();

-
                    select_opts.mode = match val {
+
                    list_opts.mode = match val {
                        "operation" => common::Mode::Operation,
                        "id" => common::Mode::Id,
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
                    };
                }
-
                Long("all") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_status(None);
+
                Long("all") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_status(None);
                }
-
                Long("draft") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_status(Some(Status::Draft));
+
                Long("draft") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_status(Some(Status::Draft));
                }
-
                Long("archived") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_status(Some(Status::Archived));
+
                Long("archived") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_status(Some(Status::Archived));
                }
-
                Long("merged") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_status(Some(Status::Merged));
+
                Long("merged") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_status(Some(Status::Merged));
                }
-
                Long("open") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_status(Some(Status::Open));
+
                Long("open") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_status(Some(Status::Open));
                }
-
                Long("authored") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_authored(true);
+
                Long("authored") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_authored(true);
                }
-
                Long("author") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts
+
                Long("author") if op == OperationName::List => {
+
                    list_opts.filter = list_opts
                        .filter
                        .with_author(terminal::args::did(&parser.value()?)?);
                }
@@ -168,32 +189,52 @@ impl Args for Options {

                    revision_id = Some(rev_id);
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "select" => op = Some(OperationName::Select),
-
                    "review" => op = Some(OperationName::Review),
-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
+
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
+
                    "list" => op = OperationName::List,
+
                    // TODO(erikli): Enable if interface was fixed.
+
                    // "review" => op = OperationName::Review,
+
                    _ => op = OperationName::Other,
                },
                Value(val) if patch_id.is_none() => {
                    let val = string(&val);
                    patch_id = Some(Rev::from(val));
                }
-
                _ => return Err(anyhow!(arg.unexpected())),
+
                _ => match op {
+
                    OperationName::List | OperationName::Review => {
+
                        return Err(anyhow!(arg.unexpected()));
+
                    }
+
                    _ => {}
+
                },
            }
        }

-
        if select_opts.mode == common::Mode::Id {
-
            select_opts.filter = Filter::default().with_status(None)
+
        // Disable forwarding if it was not enabled via `--help` or was
+
        // not disabled explicitly.
+
        let forward = forward.unwrap_or_default();
+

+
        // Show local help
+
        if help && !forward {
+
            return Err(Error::Help.into());
+
        }
+

+
        // Configure list options
+
        if list_opts.mode == common::Mode::Id {
+
            list_opts.filter = Filter::default().with_status(None)
        }
+
        list_opts.json = json;

-
        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
-
            OperationName::Review => Operation::Review {
+
        // Map local commands. Forward help and ignore `no-forward`.
+
        let op = match op {
+
            OperationName::Review if !forward => Operation::Review {
                opts: ReviewOptions {
                    patch_id,
                    revision_id,
                },
            },
-
            OperationName::Select => Operation::Select { opts: select_opts },
+
            OperationName::List if !forward => Operation::List { opts: list_opts },
+
            _ => Operation::Other { args },
        };
+

        Ok((Options { op, repo }, vec![]))
    }
}
@@ -210,20 +251,35 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
    }

    match options.op {
-
        Operation::Select { opts } => {
+
        Operation::List { opts } => {
            let profile = ctx.profile()?;
            let rid = options.repo.unwrap_or(rid);

-
            // Run TUI with patch selection interface
-
            let selection = interface::select(opts, profile, rid).await?;
-
            let selection = selection
-
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
-
                .unwrap_or_default();
+
            // Run TUI with patch list interface
+
            let selection = interface::list(opts.clone(), profile, rid).await?;
+

+
            if opts.json {
+
                let selection = selection
+
                    .map(|o| serde_json::to_string(&o).unwrap_or_default())
+
                    .unwrap_or_default();
+

+
                log::info!("About to print to `stderr`: {}", selection);
+
                log::info!("Exiting patch list interface..");

-
            log::info!("About to print to `stderr`: {}", selection);
-
            log::info!("Exiting patch selection interface..");
+
                eprint!("{selection}");
+
            } else if let Some(selection) = selection {
+
                let mut args = vec![];

-
            eprint!("{selection}");
+
                if let Some(operation) = selection.operation {
+
                    args.push(operation.to_string());
+
                }
+
                if let Some(id) = selection.ids.first() {
+
                    args.push(format!("{id}"));
+
                }
+

+
                let args = args.into_iter().map(OsString::from).collect::<Vec<_>>();
+
                let _ = crate::terminal::run_rad("patch", &args);
+
            }
        }
        Operation::Review { ref opts } => {
            log::info!("Starting patch review interface in project {rid}..");
@@ -241,6 +297,9 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
            // Run TUI with patch review interface
            interface::review(opts.clone(), profile, rid, patch_id).await?;
        }
+
        Operation::Other { args } => {
+
            let _ = crate::terminal::run_rad("patch", &args);
+
        }
    }

    Ok(())
@@ -264,16 +323,16 @@ mod interface {
    use radicle_tui::Selection;

    use crate::cob::patch;
+
    use crate::tui_patch::list;
    use crate::tui_patch::review::builder::CommentBuilder;
    use crate::tui_patch::review::ReviewAction;
-
    use crate::tui_patch::select;

    use super::review;
    use super::review::builder::ReviewBuilder;
-
    use super::{ReviewOptions, SelectOptions};
+
    use super::{ListOptions, ReviewOptions};

-
    pub async fn select(
-
        opts: SelectOptions,
+
    pub async fn list(
+
        opts: ListOptions,
        profile: Profile,
        rid: RepoId,
    ) -> anyhow::Result<Option<Selection<ObjectId>>> {
@@ -281,14 +340,14 @@ mod interface {

        log::info!("Starting patch selection interface in project {}..", rid);

-
        let context = select::Context {
+
        let context = list::Context {
            profile,
            repository,
            mode: opts.mode,
            filter: opts.filter.clone(),
        };

-
        select::App::new(context, true).run().await
+
        list::App::new(context, true).run().await
    }

    pub async fn review(
@@ -403,3 +462,166 @@ mod interface {
        Ok(())
    }
}
+

+
#[cfg(test)]
+
mod cli {
+
    use std::process::Command;
+

+
    use assert_cmd::prelude::*;
+

+
    use predicates::prelude::*;
+

+
    mod assert {
+
        use predicates::prelude::*;
+
        use predicates::str::ContainsPredicate;
+

+
        pub fn is_tui() -> ContainsPredicate {
+
            predicate::str::contains("Inappropriate ioctl for device")
+
        }
+

+
        pub fn is_rad_manual() -> ContainsPredicate {
+
            predicate::str::contains("RAD-PATCH(1)")
+
        }
+

+
        pub fn is_patch_help() -> ContainsPredicate {
+
            predicate::str::contains("Terminal interfaces for patches")
+
        }
+
    }
+

+
    #[test]
+
    fn empty_operation() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("patch");
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("patch");
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "--help"]);
+
        cmd.assert().success().stdout(assert::is_rad_manual());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "--help", "--no-forward"]);
+
        cmd.assert().success().stdout(assert::is_patch_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "--no-forward"]);
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "list"]);
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_is_not_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "list", "--no-forward"]);
+
        cmd.assert().failure().stdout(assert::is_tui());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "list", "--help"]);
+
        cmd.assert().success().stdout(assert::is_rad_manual());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "list", "--help", "--no-forward"]);
+
        cmd.assert().success().stdout(assert::is_patch_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_is_not_forwarded_reversed() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "list", "--no-forward", "--help"]);
+
        cmd.assert().success().stdout(assert::is_patch_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_show_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "show"]);
+
        cmd.assert().success().stdout(predicate::str::contains(
+
            "Error: rad patch: a patch must be provided",
+
        ));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_edit_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "edit"]);
+
        cmd.assert().success().stdout(predicate::str::contains(
+
            "Error: rad patch: a patch must be provided",
+
        ));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.args(["patch", "operation", "--no-forward"]);
+
        cmd.assert().success().stdout(predicate::str::contains(
+
            "Error: rad patch: unknown operation",
+
        ));
+

+
        Ok(())
+
    }
+
}
added bin/commands/patch/list.rs
@@ -0,0 +1,373 @@
+
#[path = "list/imui.rs"]
+
mod imui;
+
#[path = "list/rmui.rs"]
+
mod rmui;
+

+
use std::str::FromStr;
+

+
use anyhow::Result;
+

+
use ratatui::Viewport;
+
use termion::event::Key;
+

+
use radicle_tui as tui;
+

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::Text;
+

+
use tui::store;
+
use tui::ui::rm::widget::container::{Container, Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::window::{
+
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
+
};
+
use tui::ui::rm::widget::{ToWidget, Widget};
+
use tui::ui::Column;
+
use tui::ui::{span, BufferedValue};
+

+
use tui::{BoxedAny, Channel, Exit, PageStack};
+

+
use radicle::patch::PatchId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
use self::rmui::{Browser, BrowserProps};
+
use super::common::{Mode, PatchOperation};
+

+
use crate::cob::patch;
+
use crate::ui::items::{PatchItem, PatchItemFilter};
+
use crate::ui::rm::BrowserState;
+

+
type Selection = tui::Selection<PatchId>;
+

+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub mode: Mode,
+
    pub filter: patch::Filter,
+
}
+

+
pub struct App {
+
    context: Context,
+
    im: bool,
+
}
+

+
impl App {
+
    pub fn new(context: Context, im: bool) -> Self {
+
        Self { context, im }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let viewport = Viewport::Inline(20);
+

+
        if self.im {
+
            let channel = Channel::default();
+
            let state = imui::App::try_from(&self.context)?;
+

+
            tui::im(state, viewport, channel).await
+
        } else {
+
            let channel = Channel::default();
+
            let tx = channel.tx.clone();
+
            let state = State::try_from(&self.context)?;
+
            let window = Window::default()
+
                .page(AppPage::Browse, browser_page(&state, &channel))
+
                .page(AppPage::Help, help_page(&state, &channel))
+
                .to_widget(tx.clone())
+
                .on_update(|state| {
+
                    WindowProps::default()
+
                        .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
+
                        .to_boxed_any()
+
                        .into()
+
                });
+

+
            tui::rm(state, window, viewport, channel).await
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+
pub enum AppPage {
+
    Browse,
+
    Help,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct HelpState {
+
    text: TextViewState,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct State {
+
    mode: Mode,
+
    pages: PageStack<AppPage>,
+
    browser: BrowserState<PatchItem, PatchItemFilter>,
+
    help: HelpState,
+
}
+

+
impl TryFrom<&Context> for State {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let patches = patch::all(&context.profile, &context.repository)?;
+
        let search = BufferedValue::new(context.filter.to_string());
+
        let filter = PatchItemFilter::from_str(&context.filter.to_string()).unwrap_or_default();
+

+
        // Convert into UI items
+
        let mut items = vec![];
+
        for patch in patches {
+
            if let Ok(item) = PatchItem::new(&context.profile, &context.repository, patch.clone()) {
+
                items.push(item);
+
            }
+
        }
+
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+

+
        Ok(Self {
+
            mode: context.mode.clone(),
+
            pages: PageStack::new(vec![AppPage::Browse]),
+
            browser: BrowserState::build(items.clone(), filter, search),
+
            help: HelpState {
+
                text: TextViewState::default().content(help_text()),
+
            },
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Quit,
+
    Exit { operation: Option<PatchOperation> },
+
    ExitFromMode,
+
    SelectPatch { selected: Option<usize> },
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
+
    OpenHelp,
+
    LeavePage,
+
    ScrollHelp { state: TextViewState },
+
}
+

+
impl store::Update<Message> for State {
+
    type Return = Selection;
+

+
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => self.browser.selected_item().map(|issue| Exit {
+
                value: Some(Selection {
+
                    operation: operation.map(|op| op.to_string()),
+
                    ids: vec![issue.id],
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::ExitFromMode => {
+
                let operation = match self.mode {
+
                    Mode::Operation => Some(PatchOperation::Show.to_string()),
+
                    Mode::Id => None,
+
                };
+

+
                self.browser.selected_item().map(|issue| Exit {
+
                    value: Some(Selection {
+
                        operation,
+
                        ids: vec![issue.id],
+
                        args: vec![],
+
                    }),
+
                })
+
            }
+
            Message::SelectPatch { selected } => {
+
                self.browser.select_item(selected);
+
                None
+
            }
+
            Message::OpenSearch => {
+
                self.browser.show_search();
+
                None
+
            }
+
            Message::UpdateSearch { value } => {
+
                self.browser.update_search(value);
+
                self.browser.select_first_item();
+
                None
+
            }
+
            Message::ApplySearch => {
+
                self.browser.hide_search();
+
                self.browser.apply_search();
+
                None
+
            }
+
            Message::CloseSearch => {
+
                self.browser.hide_search();
+
                self.browser.reset_search();
+
                None
+
            }
+
            Message::OpenHelp => {
+
                self.pages.push(AppPage::Help);
+
                None
+
            }
+
            Message::LeavePage => {
+
                self.pages.pop();
+
                None
+
            }
+
            Message::ScrollHelp { state } => {
+
                self.help.text = state;
+
                None
+
            }
+
        }
+
    }
+
}
+

+
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Browser::new(tx.clone())
+
        .to_widget(tx.clone())
+
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
+

+
    let shortcuts = Shortcuts::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            let shortcuts = if state.browser.is_search_shown() {
+
                vec![("esc", "cancel"), ("enter", "apply")]
+
            } else {
+
                match state.mode {
+
                    Mode::Id => vec![("enter", "select"), ("/", "search")],
+
                    Mode::Operation => vec![
+
                        ("enter", "show"),
+
                        ("c", "checkout"),
+
                        ("d", "diff"),
+
                        ("/", "search"),
+
                        ("?", "help"),
+
                    ],
+
                }
+
            };
+

+
            ShortcutsProps::default()
+
                .shortcuts(&shortcuts)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, props| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+

+
            if props.handle_keys {
+
                match key {
+
                    Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
+
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    Key::Char('\n') => Some(Message::ExitFromMode),
+
                    Key::Char('c') => Some(Message::Exit {
+
                        operation: Some(PatchOperation::Checkout),
+
                    }),
+
                    Key::Char('d') => Some(Message::Exit {
+
                        operation: Some(PatchOperation::Diff),
+
                    }),
+
                    _ => None,
+
                }
+
            } else {
+
                None
+
            }
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .handle_keys(!state.browser.is_search_shown())
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Container::default()
+
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
+
            HeaderProps::default()
+
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
+
                .to_boxed_any()
+
                .into()
+
        }))
+
        .content(
+
            TextView::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, view_state, _| {
+
                    view_state
+
                        .and_then(|tv| tv.unwrap_textview())
+
                        .map(|tvs| Message::ScrollHelp { state: tvs })
+
                })
+
                .on_update(|state: &State| {
+
                    TextViewProps::default()
+
                        .state(Some(state.help.text.clone()))
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .footer(
+
            Footer::default()
+
                .to_widget(tx.clone())
+
                .on_update(|state: &State| {
+
                    FooterProps::default()
+
                        .columns(
+
                            [
+
                                Column::new(Text::raw(""), Constraint::Fill(1)),
+
                                Column::new(
+
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
+
                                    Constraint::Min(4),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone());
+

+
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
+
        ShortcutsProps::default()
+
            .shortcuts(&[("?", "close")])
+
            .to_boxed_any()
+
            .into()
+
    });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
+
            Key::Char('?') => Some(Message::LeavePage),
+
            _ => None,
+
        })
+
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
+
}
+

+
fn help_text() -> String {
+
    r#"# Generic keybindings
+

+
`↑,k`:      move cursor one line up
+
`↓,j:       move cursor one line down
+
`PageUp`:   move cursor one page up
+
`PageDown`: move cursor one page down
+
`Home`:     move cursor to the first line
+
`End`:      move cursor to the last line
+
`Esc`:      Quit / cancel
+

+
# Specific keybindings
+

+
`enter`:    Select patch (if --mode id)
+
`enter`:    Show patch
+
`c`:        Checkout patch
+
`d`:        Show patch diff
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
+
Example:    is:open is:authored improve"#
+
        .into()
+
}
added bin/commands/patch/list/imui.rs
@@ -0,0 +1,648 @@
+
use std::str::FromStr;
+

+
use anyhow::Result;
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout, Position};
+
use ratatui::style::Stylize;
+
use ratatui::text::Span;
+
use ratatui::Frame;
+

+
use radicle_tui as tui;
+

+
use tui::ui::im;
+
use tui::ui::im::widget::{PanesState, TableState, TextEditState, TextViewState, Window};
+
use tui::ui::im::Borders;
+
use tui::ui::im::Show;
+
use tui::ui::{BufferedValue, Column};
+
use tui::{store, Exit};
+

+
use crate::cob::patch;
+
use crate::tui_patch::common::{Mode, PatchOperation};
+
use crate::ui::items::{Filter, PatchItem, PatchItemFilter};
+

+
use super::{Context, Selection};
+

+
const HELP: &str = r#"# Generic keybindings
+

+
`↑,k`:      move cursor one line up
+
`↓,j:       move cursor one line down
+
`PageUp`:   move cursor one page up
+
`PageDown`: move cursor one page down
+
`Home`:     move cursor to the first line
+
`End`:      move cursor to the last line
+
`Esc`:      Quit / cancel
+

+
# Specific keybindings
+

+
`enter`:    Select patch (if --mode id)
+
`enter`:    Show patch
+
`c`:        Checkout patch
+
`d`:        Show patch diff
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
+
Example:    is:open is:authored improve"#;
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Quit,
+
    Exit {
+
        operation: Option<PatchOperation>,
+
    },
+
    ExitFromMode,
+
    PatchesChanged {
+
        state: TableState,
+
    },
+
    MainGroupChanged {
+
        state: PanesState,
+
    },
+
    PageChanged {
+
        page: Page,
+
    },
+
    HelpChanged {
+
        state: TextViewState,
+
    },
+
    ShowSearch,
+
    UpdateSearch {
+
        search: BufferedValue<TextEditState>,
+
    },
+
    HideSearch {
+
        apply: bool,
+
    },
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Page {
+
    Main,
+
    Help,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct Storage {
+
    patches: Vec<PatchItem>,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct App {
+
    storage: Storage,
+
    mode: Mode,
+
    page: Page,
+
    main_group: PanesState,
+
    patches: TableState,
+
    search: BufferedValue<TextEditState>,
+
    show_search: bool,
+
    help: TextViewState,
+
    filter: PatchItemFilter,
+
}
+

+
impl TryFrom<&Context> for App {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let patches = patch::all(&context.profile, &context.repository)?;
+
        let search = {
+
            let raw = context.filter.to_string();
+
            raw.trim().to_string()
+
        };
+
        let filter = PatchItemFilter::from_str(&context.filter.to_string()).unwrap_or_default();
+

+
        let mut items = vec![];
+
        for patch in patches {
+
            if let Ok(item) = PatchItem::new(&context.profile, &context.repository, patch.clone()) {
+
                items.push(item);
+
            }
+
        }
+
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+

+
        Ok(App {
+
            storage: Storage {
+
                patches: items.clone(),
+
            },
+
            mode: context.mode.clone(),
+
            page: Page::Main,
+
            main_group: PanesState::new(3, Some(0)),
+
            patches: TableState::new(Some(0)),
+
            search: BufferedValue::new(TextEditState {
+
                text: search.clone(),
+
                cursor: search.len(),
+
            }),
+
            show_search: false,
+
            help: TextViewState::new(Position::default()),
+
            filter,
+
        })
+
    }
+
}
+

+
impl store::Update<Message> for App {
+
    type Return = Selection;
+

+
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
+
        log::debug!("[State] Received message: {:?}", message);
+

+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => self.selected_patch().map(|issue| Exit {
+
                value: Some(Selection {
+
                    operation: operation.map(|op| op.to_string()),
+
                    ids: vec![issue.id],
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::ExitFromMode => {
+
                let operation = match self.mode {
+
                    Mode::Operation => Some(PatchOperation::Show.to_string()),
+
                    Mode::Id => None,
+
                };
+

+
                self.selected_patch().map(|issue| Exit {
+
                    value: Some(Selection {
+
                        operation,
+
                        ids: vec![issue.id],
+
                        args: vec![],
+
                    }),
+
                })
+
            }
+
            Message::PatchesChanged { state } => {
+
                self.patches = state;
+
                None
+
            }
+
            Message::MainGroupChanged { state } => {
+
                self.main_group = state;
+
                None
+
            }
+
            Message::PageChanged { page } => {
+
                self.page = page;
+
                None
+
            }
+
            Message::ShowSearch => {
+
                self.main_group = PanesState::new(3, None);
+
                self.show_search = true;
+
                None
+
            }
+
            Message::HideSearch { apply } => {
+
                self.main_group = PanesState::new(3, Some(0));
+
                self.show_search = false;
+

+
                if apply {
+
                    self.search.apply();
+
                } else {
+
                    self.search.reset();
+
                }
+

+
                self.filter =
+
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
+

+
                None
+
            }
+
            Message::UpdateSearch { search } => {
+
                self.search = search;
+
                self.filter =
+
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
+
                self.patches.select_first();
+
                None
+
            }
+
            Message::HelpChanged { state } => {
+
                self.help = state;
+
                None
+
            }
+
        }
+
    }
+
}
+

+
impl Show<Message> for App {
+
    fn show(&self, ctx: &im::Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, |ui| {
+
            match self.page {
+
                Page::Main => {
+
                    let show_search = self.show_search;
+
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
+
                    let mut group_focus = self.main_group.focus();
+

+
                    ui.panes(
+
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
+
                        &mut page_focus,
+
                        |ui| {
+
                            let group = ui.panes(
+
                                im::Layout::Expandable3 { left_only: true },
+
                                &mut group_focus,
+
                                |ui| {
+
                                    self.show_patches(frame, ui);
+

+
                                    ui.text_view(
+
                                        frame,
+
                                        String::new(),
+
                                        &mut Position::default(),
+
                                        Some(Borders::All),
+
                                    );
+
                                    ui.text_view(
+
                                        frame,
+
                                        String::new(),
+
                                        &mut Position::default(),
+
                                        Some(Borders::All),
+
                                    );
+
                                },
+
                            );
+
                            if group.response.changed {
+
                                ui.send_message(Message::MainGroupChanged {
+
                                    state: PanesState::new(3, group_focus),
+
                                });
+
                            }
+

+
                            if show_search {
+
                                self.show_search_text_edit(frame, ui);
+
                            } else {
+
                                ui.layout(Layout::vertical([1, 1]), None, |ui| {
+
                                    ui.bar(
+
                                        frame,
+
                                        match group_focus {
+
                                            Some(0) => browser_context(ui, self),
+
                                            _ => default_context(ui),
+
                                        },
+
                                        Some(Borders::None),
+
                                    );
+

+
                                    ui.shortcuts(
+
                                        frame,
+
                                        &match self.mode {
+
                                            Mode::Id => {
+
                                                [("enter", "select"), ("/", "search")].to_vec()
+
                                            }
+
                                            Mode::Operation => [
+
                                                ("enter", "show"),
+
                                                ("c", "checkout"),
+
                                                ("d", "diff"),
+
                                                ("/", "search"),
+
                                                ("?", "help"),
+
                                            ]
+
                                            .to_vec(),
+
                                        },
+
                                        '∙',
+
                                    );
+
                                });
+

+
                                if ui.input_global(|key| key == Key::Esc) {
+
                                    ui.send_message(Message::Quit);
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('?')) {
+
                                    ui.send_message(Message::PageChanged { page: Page::Help });
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('\n')) {
+
                                    ui.send_message(Message::ExitFromMode);
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('d')) {
+
                                    ui.send_message(Message::Exit {
+
                                        operation: Some(PatchOperation::Diff),
+
                                    });
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('c')) {
+
                                    ui.send_message(Message::Exit {
+
                                        operation: Some(PatchOperation::Checkout),
+
                                    });
+
                                }
+
                            }
+
                        },
+
                    );
+
                }
+

+
                Page::Help => {
+
                    let mut cursor = self.help.cursor();
+

+
                    let layout = Layout::vertical([
+
                        Constraint::Length(3),
+
                        Constraint::Fill(1),
+
                        Constraint::Length(1),
+
                        Constraint::Length(1),
+
                    ]);
+

+
                    ui.composite(layout, 1, |ui| {
+
                        ui.columns(
+
                            frame,
+
                            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
                            Some(Borders::Top),
+
                        );
+

+
                        let text_view = ui.text_view(
+
                            frame,
+
                            HELP.to_string(),
+
                            &mut cursor,
+
                            Some(Borders::BottomSides),
+
                        );
+
                        if text_view.changed {
+
                            ui.send_message(Message::HelpChanged {
+
                                state: TextViewState::new(cursor),
+
                            })
+
                        }
+

+
                        ui.bar(
+
                            frame,
+
                            [
+
                                Column::new(
+
                                    Span::raw(" ".to_string())
+
                                        .into_left_aligned_line()
+
                                        .style(ui.theme().bar_on_black_style),
+
                                    Constraint::Fill(1),
+
                                ),
+
                                Column::new(
+
                                    Span::raw(" ")
+
                                        .into_right_aligned_line()
+
                                        .cyan()
+
                                        .dim()
+
                                        .reversed(),
+
                                    Constraint::Length(6),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                            Some(Borders::None),
+
                        );
+

+
                        ui.shortcuts(frame, &[("?", "close")], '∙');
+
                    });
+

+
                    if ui.input_global(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::PageChanged { page: Page::Main });
+
                    }
+
                    if ui.input_global(|key| key == Key::Esc) {
+
                        ui.send_message(Message::Quit);
+
                    }
+
                }
+
            }
+
            if ui.input_global(|key| key == Key::Ctrl('c')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
+
}
+

+
impl App {
+
    pub fn show_patches(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let patches = self
+
            .storage
+
            .patches
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .cloned()
+
            .collect::<Vec<_>>();
+
        let mut selected = self.patches.selected();
+

+
        let header = [
+
            Column::new(Span::raw(" ● ").bold(), Constraint::Length(3)),
+
            Column::new(Span::raw("ID").bold(), Constraint::Length(8)),
+
            Column::new(Span::raw("Title").bold(), Constraint::Fill(1)),
+
            Column::new(Span::raw("Author").bold(), Constraint::Length(16)).hide_small(),
+
            Column::new("", Constraint::Length(16)).hide_medium(),
+
            Column::new(Span::raw("Head").bold(), Constraint::Length(8)).hide_small(),
+
            Column::new(Span::raw("+").bold(), Constraint::Length(6)).hide_small(),
+
            Column::new(Span::raw("-").bold(), Constraint::Length(6)).hide_small(),
+
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)).hide_small(),
+
        ];
+

+
        let table = ui.headered_table(frame, &mut selected, &patches, header.clone(), header);
+
        if table.changed {
+
            ui.send_message(Message::PatchesChanged {
+
                state: TableState::new(selected),
+
            });
+
        }
+

+
        // TODO(erikli): Should only work if table has focus
+
        if ui.input_global(|key| key == Key::Char('/')) {
+
            ui.send_message(Message::ShowSearch);
+
        }
+
    }
+

+
    pub fn show_search_text_edit(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let (mut search_text, mut search_cursor) = (
+
            self.search.clone().read().text,
+
            self.search.clone().read().cursor,
+
        );
+
        let mut search = self.search.clone();
+

+
        let text_edit = ui.text_edit_labeled_singleline(
+
            frame,
+
            &mut search_text,
+
            &mut search_cursor,
+
            "Search".to_string(),
+
            Some(Borders::Spacer { top: 0, left: 0 }),
+
        );
+

+
        if text_edit.changed {
+
            search.write(TextEditState {
+
                text: search_text,
+
                cursor: search_cursor,
+
            });
+
            ui.send_message(Message::UpdateSearch { search });
+
        }
+

+
        if ui.input_global(|key| key == Key::Esc) {
+
            ui.send_message(Message::HideSearch { apply: false });
+
        }
+
        if ui.input_global(|key| key == Key::Char('\n')) {
+
            ui.send_message(Message::HideSearch { apply: true });
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn selected_patch(&self) -> Option<&PatchItem> {
+
        let patches = self
+
            .storage
+
            .patches
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .collect::<Vec<_>>();
+

+
        self.patches
+
            .selected()
+
            .and_then(|selected| patches.get(selected))
+
            .copied()
+
    }
+
}
+

+
fn browser_context<'a>(ui: &im::Ui<Message>, app: &'a App) -> Vec<Column<'a>> {
+
    let search = app.search.read().text;
+
    let total_count = app.storage.patches.len();
+
    let filtered_count = app
+
        .storage
+
        .patches
+
        .iter()
+
        .filter(|patch| app.filter.matches(patch))
+
        .collect::<Vec<_>>()
+
        .len();
+
    let experimental = false;
+

+
    if experimental {
+
        [
+
            Column::new(
+
                Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                Constraint::Length(8),
+
            ),
+
            Column::new(Span::raw("".to_string()), Constraint::Length(1)),
+
            Column::new(
+
                Span::raw(format!(" {} ", search))
+
                    .into_left_aligned_line()
+
                    .cyan()
+
                    .dim()
+
                    .reversed(),
+
                Constraint::Length((search.chars().count() + 2) as u16),
+
            ),
+
            Column::new(Span::raw("".to_string()), Constraint::Fill(1)),
+
            Column::new(
+
                Span::raw(" 0% ")
+
                    .into_right_aligned_line()
+
                    .red()
+
                    .dim()
+
                    .reversed(),
+
                Constraint::Length(6),
+
            ),
+
        ]
+
        .to_vec()
+
    } else {
+
        let filtered_counts = format!(" {filtered_count}/{total_count} ");
+
        let state_counts =
+
            app.storage
+
                .patches
+
                .iter()
+
                .fold((0, 0, 0, 0), |counts, patch| match patch.state {
+
                    radicle::patch::State::Draft => (counts.0 + 1, counts.1, counts.2, counts.3),
+
                    radicle::patch::State::Open { conflicts: _ } => {
+
                        (counts.0, counts.1 + 1, counts.2, counts.3)
+
                    }
+
                    radicle::patch::State::Archived => (counts.0, counts.1, counts.2 + 1, counts.3),
+
                    radicle::patch::State::Merged {
+
                        revision: _,
+
                        commit: _,
+
                    } => (counts.0, counts.1, counts.2, counts.3 + 1),
+
                });
+

+
        if app.filter.is_default() {
+
            let draft = format!(" {} ", state_counts.0);
+
            let open = format!(" {} ", state_counts.1);
+
            let archived = format!(" {} ", state_counts.2);
+
            let merged = format!(" {} ", state_counts.3);
+
            [
+
                Column::new(
+
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(8),
+
                ),
+
                Column::new(
+
                    Span::raw(format!(" {search} "))
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    Span::raw("●")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(draft.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(draft.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw("●")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .green()
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(open.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(open.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw("●")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .yellow()
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(archived.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(archived.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw("✔")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .magenta()
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(merged.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(merged.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw(filtered_counts.clone())
+
                        .into_right_aligned_line()
+
                        .cyan()
+
                        .dim()
+
                        .reversed(),
+
                    Constraint::Length(filtered_counts.chars().count() as u16),
+
                ),
+
            ]
+
            .to_vec()
+
        } else {
+
            [
+
                Column::new(
+
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(8),
+
                ),
+
                Column::new(
+
                    Span::raw(format!(" {search} "))
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    Span::raw(filtered_counts.clone())
+
                        .into_right_aligned_line()
+
                        .cyan()
+
                        .dim()
+
                        .reversed(),
+
                    Constraint::Length(filtered_counts.chars().count() as u16),
+
                ),
+
            ]
+
            .to_vec()
+
        }
+
    }
+
}
+

+
fn default_context<'a>(ui: &im::Ui<Message>) -> Vec<Column<'a>> {
+
    [
+
        Column::new(
+
            Span::raw(" ".to_string())
+
                .into_left_aligned_line()
+
                .style(ui.theme().bar_on_black_style),
+
            Constraint::Fill(1),
+
        ),
+
        Column::new(
+
            Span::raw(" 0% ")
+
                .into_right_aligned_line()
+
                .cyan()
+
                .dim()
+
                .reversed(),
+
            Constraint::Length(6),
+
        ),
+
    ]
+
    .to_vec()
+
}
added bin/commands/patch/list/rmui.rs
@@ -0,0 +1,330 @@
+
use std::collections::HashMap;
+
use std::str::FromStr;
+
use std::vec;
+

+
use ratatui::Frame;
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Text};
+

+
use radicle::patch;
+
use radicle::patch::Status;
+

+
use radicle_tui as tui;
+

+
use tui::ui::rm::widget;
+
use tui::ui::rm::widget::container::{
+
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
};
+
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
+
use tui::ui::rm::widget::list::{Table, TableProps};
+
use tui::ui::rm::widget::ViewProps;
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::span;
+
use tui::ui::Column;
+

+
use tui::BoxedAny;
+

+
use crate::ui::items::{PatchItem, PatchItemFilter};
+

+
use super::{Message, State};
+

+
type Widget = widget::Widget<State, Message>;
+

+
#[derive(Clone, Default)]
+
pub struct BrowserProps<'a> {
+
    /// Filtered patches.
+
    patches: Vec<PatchItem>,
+
    /// Patch statistics.
+
    stats: HashMap<String, usize>,
+
    /// Header columns
+
    header: Vec<Column<'a>>,
+
    /// Table columns
+
    columns: Vec<Column<'a>>,
+
    /// If search widget should be shown.
+
    show_search: bool,
+
    /// Current search string.
+
    search: String,
+
}
+

+
impl<'a> From<&State> for BrowserProps<'a> {
+
    fn from(state: &State) -> Self {
+
        let mut draft = 0;
+
        let mut open = 0;
+
        let mut archived = 0;
+
        let mut merged = 0;
+

+
        let patches = state.browser.items();
+

+
        for patch in &patches {
+
            match patch.state {
+
                patch::State::Draft => draft += 1,
+
                patch::State::Open { conflicts: _ } => open += 1,
+
                patch::State::Archived => archived += 1,
+
                patch::State::Merged {
+
                    commit: _,
+
                    revision: _,
+
                } => merged += 1,
+
            }
+
        }
+

+
        let stats = HashMap::from([
+
            ("Draft".to_string(), draft),
+
            ("Open".to_string(), open),
+
            ("Archived".to_string(), archived),
+
            ("Merged".to_string(), merged),
+
        ]);
+

+
        Self {
+
            patches,
+
            stats,
+
            header: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(1)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Head", Constraint::Length(8)).hide_small(),
+
                Column::new("+", Constraint::Length(6)).hide_small(),
+
                Column::new("-", Constraint::Length(6)).hide_small(),
+
                Column::new("Updated", Constraint::Length(16)).hide_small(),
+
            ]
+
            .to_vec(),
+
            columns: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(1)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Head", Constraint::Length(8)).hide_small(),
+
                Column::new("+", Constraint::Length(6)).hide_small(),
+
                Column::new("-", Constraint::Length(6)).hide_small(),
+
                Column::new("Updated", Constraint::Length(16)).hide_small(),
+
            ]
+
            .to_vec(),
+
            show_search: state.browser.is_search_shown(),
+
            search: state.browser.read_search(),
+
        }
+
    }
+
}
+

+
pub struct Browser {
+
    /// Patches widget
+
    patches: Widget,
+
    /// Search widget
+
    search: Widget,
+
}
+

+
impl Browser {
+
    pub fn new(tx: UnboundedSender<Message>) -> Self {
+
        Self {
+
            patches: Container::default()
+
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+
                    HeaderProps::default()
+
                        .columns(props.header.clone())
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(
+
                    Table::<State, Message, PatchItem, 9>::default()
+
                        .to_widget(tx.clone())
+
                        .on_event(|_, s, _| {
+
                            let (selected, _) =
+
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
+
                            Some(Message::SelectPatch {
+
                                selected: Some(selected),
+
                            })
+
                        })
+
                        .on_update(|state| {
+
                            // TODO: remove and use state directly
+
                            let props = BrowserProps::from(state);
+
                            TableProps::default()
+
                                .columns(props.columns)
+
                                .items(state.browser.items())
+
                                .selected(state.browser.selected())
+
                                .to_boxed_any()
+
                                .into()
+
                        }),
+
                )
+
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+

+
                    FooterProps::default()
+
                        .columns(browser_footer(&props))
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .to_widget(tx.clone())
+
                .on_update(|state| {
+
                    ContainerProps::default()
+
                        .hide_footer(BrowserProps::from(state).show_search)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
            search: TextField::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, s, _| {
+
                    Some(Message::UpdateSearch {
+
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    TextFieldProps::default()
+
                        .text(&state.browser.read_search())
+
                        .title("Search")
+
                        .inline(true)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        }
+
    }
+
}
+

+
impl View for Browser {
+
    type Message = Message;
+
    type State = State;
+

+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
+
            match key {
+
                Key::Esc => {
+
                    self.search.reset();
+
                    Some(Message::CloseSearch)
+
                }
+
                Key::Char('\n') => Some(Message::ApplySearch),
+
                _ => {
+
                    self.search.handle_event(key);
+
                    None
+
                }
+
            }
+
        } else {
+
            match key {
+
                Key::Char('/') => Some(Message::OpenSearch),
+
                _ => {
+
                    self.patches.handle_event(key);
+
                    None
+
                }
+
            }
+
        }
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        self.patches.update(state);
+
        self.search.update(state);
+
    }
+

+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
+
            let [_, search_area, _] = Layout::horizontal([
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
                Constraint::Length(1),
+
            ])
+
            .areas(search_area);
+

+
            self.patches.render(RenderProps::from(table_area), frame);
+
            self.search
+
                .render(RenderProps::from(search_area).focus(render.focus), frame);
+
        } else {
+
            self.patches.render(render, frame);
+
        }
+
    }
+
}
+

+
fn browser_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
+
    let filter = PatchItemFilter::from_str(&props.search).unwrap_or_default();
+

+
    let search = Line::from(vec![
+
        span::default(" Search ").cyan().dim().reversed(),
+
        span::default(" "),
+
        span::default(&props.search.to_string()).gray().dim(),
+
    ]);
+

+
    let draft = Line::from(vec![
+
        span::default(&props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
+
        span::default(" Draft").dim(),
+
    ]);
+

+
    let open = Line::from(vec![
+
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
        span::default(" Open").dim(),
+
    ]);
+

+
    let merged = Line::from(vec![
+
        span::default(&props.stats.get("Merged").unwrap_or(&0).to_string())
+
            .magenta()
+
            .dim(),
+
        span::default(" Merged").dim(),
+
    ]);
+

+
    let archived = Line::from(vec![
+
        span::default(&props.stats.get("Archived").unwrap_or(&0).to_string())
+
            .yellow()
+
            .dim(),
+
        span::default(" Archived").dim(),
+
    ]);
+

+
    let sum = Line::from(vec![
+
        span::default("Σ ").dim(),
+
        span::default(&props.patches.len().to_string()).dim(),
+
    ]);
+

+
    match filter.status() {
+
        Some(state) => {
+
            let block = match state {
+
                Status::Draft => draft,
+
                Status::Open => open,
+
                Status::Merged => merged,
+
                Status::Archived => archived,
+
            };
+

+
            vec![
+
                Column::new(Text::from(search), Constraint::Fill(1)),
+
                Column::new(
+
                    Text::from(block.clone()),
+
                    Constraint::Min(block.width() as u16),
+
                ),
+
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
            ]
+
        }
+
        None => vec![
+
            Column::new(Text::from(search), Constraint::Fill(1)),
+
            Column::new(
+
                Text::from(draft.clone()),
+
                Constraint::Min(draft.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(open.clone()),
+
                Constraint::Min(open.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(merged.clone()),
+
                Constraint::Min(merged.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(archived.clone()),
+
                Constraint::Min(archived.width() as u16),
+
            ),
+
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
        ],
+
    }
+
}
deleted bin/commands/patch/select.rs
@@ -1,373 +0,0 @@
-
#[path = "select/imui.rs"]
-
mod imui;
-
#[path = "select/rmui.rs"]
-
mod rmui;
-

-
use std::str::FromStr;
-

-
use anyhow::Result;
-

-
use ratatui::Viewport;
-
use termion::event::Key;
-

-
use radicle_tui as tui;
-

-
use ratatui::layout::Constraint;
-
use ratatui::style::Stylize;
-
use ratatui::text::Text;
-

-
use tui::store;
-
use tui::ui::rm::widget::container::{Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::rm::widget::window::{
-
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
-
};
-
use tui::ui::rm::widget::{ToWidget, Widget};
-
use tui::ui::Column;
-
use tui::ui::{span, BufferedValue};
-

-
use tui::{BoxedAny, Channel, Exit, PageStack};
-

-
use radicle::patch::PatchId;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

-
use self::rmui::{Browser, BrowserProps};
-
use super::common::{Mode, PatchOperation};
-

-
use crate::cob::patch;
-
use crate::ui::items::{PatchItem, PatchItemFilter};
-
use crate::ui::rm::BrowserState;
-

-
type Selection = tui::Selection<PatchId>;
-

-
pub struct Context {
-
    pub profile: Profile,
-
    pub repository: Repository,
-
    pub mode: Mode,
-
    pub filter: patch::Filter,
-
}
-

-
pub struct App {
-
    context: Context,
-
    im: bool,
-
}
-

-
impl App {
-
    pub fn new(context: Context, im: bool) -> Self {
-
        Self { context, im }
-
    }
-

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let viewport = Viewport::Inline(20);
-

-
        if self.im {
-
            let channel = Channel::default();
-
            let state = imui::App::try_from(&self.context)?;
-

-
            tui::im(state, viewport, channel).await
-
        } else {
-
            let channel = Channel::default();
-
            let tx = channel.tx.clone();
-
            let state = State::try_from(&self.context)?;
-
            let window = Window::default()
-
                .page(AppPage::Browse, browser_page(&state, &channel))
-
                .page(AppPage::Help, help_page(&state, &channel))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    WindowProps::default()
-
                        .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
-
                        .to_boxed_any()
-
                        .into()
-
                });
-

-
            tui::rm(state, window, viewport, channel).await
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum AppPage {
-
    Browse,
-
    Help,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct HelpState {
-
    text: TextViewState,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct State {
-
    mode: Mode,
-
    pages: PageStack<AppPage>,
-
    browser: BrowserState<PatchItem, PatchItemFilter>,
-
    help: HelpState,
-
}
-

-
impl TryFrom<&Context> for State {
-
    type Error = anyhow::Error;
-

-
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
-
        let patches = patch::all(&context.profile, &context.repository)?;
-
        let search = BufferedValue::new(context.filter.to_string());
-
        let filter = PatchItemFilter::from_str(&context.filter.to_string()).unwrap_or_default();
-

-
        // Convert into UI items
-
        let mut items = vec![];
-
        for patch in patches {
-
            if let Ok(item) = PatchItem::new(&context.profile, &context.repository, patch.clone()) {
-
                items.push(item);
-
            }
-
        }
-
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
-

-
        Ok(Self {
-
            mode: context.mode.clone(),
-
            pages: PageStack::new(vec![AppPage::Browse]),
-
            browser: BrowserState::build(items.clone(), filter, search),
-
            help: HelpState {
-
                text: TextViewState::default().content(help_text()),
-
            },
-
        })
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Quit,
-
    Exit { operation: Option<PatchOperation> },
-
    ExitFromMode,
-
    SelectPatch { selected: Option<usize> },
-
    OpenSearch,
-
    UpdateSearch { value: String },
-
    ApplySearch,
-
    CloseSearch,
-
    OpenHelp,
-
    LeavePage,
-
    ScrollHelp { state: TextViewState },
-
}
-

-
impl store::Update<Message> for State {
-
    type Return = Selection;
-

-
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
-
        match message {
-
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.browser.selected_item().map(|issue| Exit {
-
                value: Some(Selection {
-
                    operation: operation.map(|op| op.to_string()),
-
                    ids: vec![issue.id],
-
                    args: vec![],
-
                }),
-
            }),
-
            Message::ExitFromMode => {
-
                let operation = match self.mode {
-
                    Mode::Operation => Some(PatchOperation::Show.to_string()),
-
                    Mode::Id => None,
-
                };
-

-
                self.browser.selected_item().map(|issue| Exit {
-
                    value: Some(Selection {
-
                        operation,
-
                        ids: vec![issue.id],
-
                        args: vec![],
-
                    }),
-
                })
-
            }
-
            Message::SelectPatch { selected } => {
-
                self.browser.select_item(selected);
-
                None
-
            }
-
            Message::OpenSearch => {
-
                self.browser.show_search();
-
                None
-
            }
-
            Message::UpdateSearch { value } => {
-
                self.browser.update_search(value);
-
                self.browser.select_first_item();
-
                None
-
            }
-
            Message::ApplySearch => {
-
                self.browser.hide_search();
-
                self.browser.apply_search();
-
                None
-
            }
-
            Message::CloseSearch => {
-
                self.browser.hide_search();
-
                self.browser.reset_search();
-
                None
-
            }
-
            Message::OpenHelp => {
-
                self.pages.push(AppPage::Help);
-
                None
-
            }
-
            Message::LeavePage => {
-
                self.pages.pop();
-
                None
-
            }
-
            Message::ScrollHelp { state } => {
-
                self.help.text = state;
-
                None
-
            }
-
        }
-
    }
-
}
-

-
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Browser::new(tx.clone())
-
        .to_widget(tx.clone())
-
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
-

-
    let shortcuts = Shortcuts::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state: &State| {
-
            let shortcuts = if state.browser.is_search_shown() {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                match state.mode {
-
                    Mode::Id => vec![("enter", "select"), ("/", "search")],
-
                    Mode::Operation => vec![
-
                        ("enter", "show"),
-
                        ("c", "checkout"),
-
                        ("d", "diff"),
-
                        ("/", "search"),
-
                        ("?", "help"),
-
                    ],
-
                }
-
            };
-

-
            ShortcutsProps::default()
-
                .shortcuts(&shortcuts)
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, props| {
-
            let default = PageProps::default();
-
            let props = props
-
                .and_then(|props| props.inner_ref::<PageProps>())
-
                .unwrap_or(&default);
-

-
            if props.handle_keys {
-
                match key {
-
                    Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
-
                    Key::Char('?') => Some(Message::OpenHelp),
-
                    Key::Char('\n') => Some(Message::ExitFromMode),
-
                    Key::Char('c') => Some(Message::Exit {
-
                        operation: Some(PatchOperation::Checkout),
-
                    }),
-
                    Key::Char('d') => Some(Message::Exit {
-
                        operation: Some(PatchOperation::Diff),
-
                    }),
-
                    _ => None,
-
                }
-
            } else {
-
                None
-
            }
-
        })
-
        .on_update(|state: &State| {
-
            PageProps::default()
-
                .handle_keys(!state.browser.is_search_shown())
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

-
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Container::default()
-
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
-
            HeaderProps::default()
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .to_boxed_any()
-
                .into()
-
        }))
-
        .content(
-
            TextView::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, view_state, _| {
-
                    view_state
-
                        .and_then(|tv| tv.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp { state: tvs })
-
                })
-
                .on_update(|state: &State| {
-
                    TextViewProps::default()
-
                        .state(Some(state.help.text.clone()))
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .footer(
-
            Footer::default()
-
                .to_widget(tx.clone())
-
                .on_update(|state: &State| {
-
                    FooterProps::default()
-
                        .columns(
-
                            [
-
                                Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
-
                                    Constraint::Min(4),
-
                                ),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone());
-

-
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
-
        ShortcutsProps::default()
-
            .shortcuts(&[("?", "close")])
-
            .to_boxed_any()
-
            .into()
-
    });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, _| match key {
-
            Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
-
            Key::Char('?') => Some(Message::LeavePage),
-
            _ => None,
-
        })
-
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
-
}
-

-
fn help_text() -> String {
-
    r#"# Generic keybindings
-

-
`↑,k`:      move cursor one line up
-
`↓,j:       move cursor one line down
-
`PageUp`:   move cursor one page up
-
`PageDown`: move cursor one page down
-
`Home`:     move cursor to the first line
-
`End`:      move cursor to the last line
-
`Esc`:      Quit / cancel
-

-
# Specific keybindings
-

-
`enter`:    Select patch (if --mode id)
-
`enter`:    Show patch
-
`c`:        Checkout patch
-
`d`:        Show patch diff
-
`/`:        Search
-
`?`:        Show help
-

-
# Searching
-

-
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
-
Example:    is:open is:authored improve"#
-
        .into()
-
}
deleted bin/commands/patch/select/imui.rs
@@ -1,648 +0,0 @@
-
use std::str::FromStr;
-

-
use anyhow::Result;
-

-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout, Position};
-
use ratatui::style::Stylize;
-
use ratatui::text::Span;
-
use ratatui::Frame;
-

-
use radicle_tui as tui;
-

-
use tui::ui::im;
-
use tui::ui::im::widget::{PanesState, TableState, TextEditState, TextViewState, Window};
-
use tui::ui::im::Borders;
-
use tui::ui::im::Show;
-
use tui::ui::{BufferedValue, Column};
-
use tui::{store, Exit};
-

-
use crate::cob::patch;
-
use crate::tui_patch::common::{Mode, PatchOperation};
-
use crate::ui::items::{Filter, PatchItem, PatchItemFilter};
-

-
use super::{Context, Selection};
-

-
const HELP: &str = r#"# Generic keybindings
-

-
`↑,k`:      move cursor one line up
-
`↓,j:       move cursor one line down
-
`PageUp`:   move cursor one page up
-
`PageDown`: move cursor one page down
-
`Home`:     move cursor to the first line
-
`End`:      move cursor to the last line
-
`Esc`:      Quit / cancel
-

-
# Specific keybindings
-

-
`enter`:    Select patch (if --mode id)
-
`enter`:    Show patch
-
`c`:        Checkout patch
-
`d`:        Show patch diff
-
`/`:        Search
-
`?`:        Show help
-

-
# Searching
-

-
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
-
Example:    is:open is:authored improve"#;
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Quit,
-
    Exit {
-
        operation: Option<PatchOperation>,
-
    },
-
    ExitFromMode,
-
    PatchesChanged {
-
        state: TableState,
-
    },
-
    MainGroupChanged {
-
        state: PanesState,
-
    },
-
    PageChanged {
-
        page: Page,
-
    },
-
    HelpChanged {
-
        state: TextViewState,
-
    },
-
    ShowSearch,
-
    UpdateSearch {
-
        search: BufferedValue<TextEditState>,
-
    },
-
    HideSearch {
-
        apply: bool,
-
    },
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Page {
-
    Main,
-
    Help,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct Storage {
-
    patches: Vec<PatchItem>,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct App {
-
    storage: Storage,
-
    mode: Mode,
-
    page: Page,
-
    main_group: PanesState,
-
    patches: TableState,
-
    search: BufferedValue<TextEditState>,
-
    show_search: bool,
-
    help: TextViewState,
-
    filter: PatchItemFilter,
-
}
-

-
impl TryFrom<&Context> for App {
-
    type Error = anyhow::Error;
-

-
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
-
        let patches = patch::all(&context.profile, &context.repository)?;
-
        let search = {
-
            let raw = context.filter.to_string();
-
            raw.trim().to_string()
-
        };
-
        let filter = PatchItemFilter::from_str(&context.filter.to_string()).unwrap_or_default();
-

-
        let mut items = vec![];
-
        for patch in patches {
-
            if let Ok(item) = PatchItem::new(&context.profile, &context.repository, patch.clone()) {
-
                items.push(item);
-
            }
-
        }
-
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
-

-
        Ok(App {
-
            storage: Storage {
-
                patches: items.clone(),
-
            },
-
            mode: context.mode.clone(),
-
            page: Page::Main,
-
            main_group: PanesState::new(3, Some(0)),
-
            patches: TableState::new(Some(0)),
-
            search: BufferedValue::new(TextEditState {
-
                text: search.clone(),
-
                cursor: search.len(),
-
            }),
-
            show_search: false,
-
            help: TextViewState::new(Position::default()),
-
            filter,
-
        })
-
    }
-
}
-

-
impl store::Update<Message> for App {
-
    type Return = Selection;
-

-
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
-
        log::debug!("[State] Received message: {:?}", message);
-

-
        match message {
-
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.selected_patch().map(|issue| Exit {
-
                value: Some(Selection {
-
                    operation: operation.map(|op| op.to_string()),
-
                    ids: vec![issue.id],
-
                    args: vec![],
-
                }),
-
            }),
-
            Message::ExitFromMode => {
-
                let operation = match self.mode {
-
                    Mode::Operation => Some(PatchOperation::Show.to_string()),
-
                    Mode::Id => None,
-
                };
-

-
                self.selected_patch().map(|issue| Exit {
-
                    value: Some(Selection {
-
                        operation,
-
                        ids: vec![issue.id],
-
                        args: vec![],
-
                    }),
-
                })
-
            }
-
            Message::PatchesChanged { state } => {
-
                self.patches = state;
-
                None
-
            }
-
            Message::MainGroupChanged { state } => {
-
                self.main_group = state;
-
                None
-
            }
-
            Message::PageChanged { page } => {
-
                self.page = page;
-
                None
-
            }
-
            Message::ShowSearch => {
-
                self.main_group = PanesState::new(3, None);
-
                self.show_search = true;
-
                None
-
            }
-
            Message::HideSearch { apply } => {
-
                self.main_group = PanesState::new(3, Some(0));
-
                self.show_search = false;
-

-
                if apply {
-
                    self.search.apply();
-
                } else {
-
                    self.search.reset();
-
                }
-

-
                self.filter =
-
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
-

-
                None
-
            }
-
            Message::UpdateSearch { search } => {
-
                self.search = search;
-
                self.filter =
-
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
-
                self.patches.select_first();
-
                None
-
            }
-
            Message::HelpChanged { state } => {
-
                self.help = state;
-
                None
-
            }
-
        }
-
    }
-
}
-

-
impl Show<Message> for App {
-
    fn show(&self, ctx: &im::Context<Message>, frame: &mut Frame) -> Result<()> {
-
        Window::default().show(ctx, |ui| {
-
            match self.page {
-
                Page::Main => {
-
                    let show_search = self.show_search;
-
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
-
                    let mut group_focus = self.main_group.focus();
-

-
                    ui.panes(
-
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
-
                        &mut page_focus,
-
                        |ui| {
-
                            let group = ui.panes(
-
                                im::Layout::Expandable3 { left_only: true },
-
                                &mut group_focus,
-
                                |ui| {
-
                                    self.show_patches(frame, ui);
-

-
                                    ui.text_view(
-
                                        frame,
-
                                        String::new(),
-
                                        &mut Position::default(),
-
                                        Some(Borders::All),
-
                                    );
-
                                    ui.text_view(
-
                                        frame,
-
                                        String::new(),
-
                                        &mut Position::default(),
-
                                        Some(Borders::All),
-
                                    );
-
                                },
-
                            );
-
                            if group.response.changed {
-
                                ui.send_message(Message::MainGroupChanged {
-
                                    state: PanesState::new(3, group_focus),
-
                                });
-
                            }
-

-
                            if show_search {
-
                                self.show_search_text_edit(frame, ui);
-
                            } else {
-
                                ui.layout(Layout::vertical([1, 1]), None, |ui| {
-
                                    ui.bar(
-
                                        frame,
-
                                        match group_focus {
-
                                            Some(0) => browser_context(ui, self),
-
                                            _ => default_context(ui),
-
                                        },
-
                                        Some(Borders::None),
-
                                    );
-

-
                                    ui.shortcuts(
-
                                        frame,
-
                                        &match self.mode {
-
                                            Mode::Id => {
-
                                                [("enter", "select"), ("/", "search")].to_vec()
-
                                            }
-
                                            Mode::Operation => [
-
                                                ("enter", "show"),
-
                                                ("c", "checkout"),
-
                                                ("d", "diff"),
-
                                                ("/", "search"),
-
                                                ("?", "help"),
-
                                            ]
-
                                            .to_vec(),
-
                                        },
-
                                        '∙',
-
                                    );
-
                                });
-

-
                                if ui.input_global(|key| key == Key::Esc) {
-
                                    ui.send_message(Message::Quit);
-
                                }
-
                                if ui.input_global(|key| key == Key::Char('?')) {
-
                                    ui.send_message(Message::PageChanged { page: Page::Help });
-
                                }
-
                                if ui.input_global(|key| key == Key::Char('\n')) {
-
                                    ui.send_message(Message::ExitFromMode);
-
                                }
-
                                if ui.input_global(|key| key == Key::Char('d')) {
-
                                    ui.send_message(Message::Exit {
-
                                        operation: Some(PatchOperation::Diff),
-
                                    });
-
                                }
-
                                if ui.input_global(|key| key == Key::Char('c')) {
-
                                    ui.send_message(Message::Exit {
-
                                        operation: Some(PatchOperation::Checkout),
-
                                    });
-
                                }
-
                            }
-
                        },
-
                    );
-
                }
-

-
                Page::Help => {
-
                    let mut cursor = self.help.cursor();
-

-
                    let layout = Layout::vertical([
-
                        Constraint::Length(3),
-
                        Constraint::Fill(1),
-
                        Constraint::Length(1),
-
                        Constraint::Length(1),
-
                    ]);
-

-
                    ui.composite(layout, 1, |ui| {
-
                        ui.columns(
-
                            frame,
-
                            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
-
                            Some(Borders::Top),
-
                        );
-

-
                        let text_view = ui.text_view(
-
                            frame,
-
                            HELP.to_string(),
-
                            &mut cursor,
-
                            Some(Borders::BottomSides),
-
                        );
-
                        if text_view.changed {
-
                            ui.send_message(Message::HelpChanged {
-
                                state: TextViewState::new(cursor),
-
                            })
-
                        }
-

-
                        ui.bar(
-
                            frame,
-
                            [
-
                                Column::new(
-
                                    Span::raw(" ".to_string())
-
                                        .into_left_aligned_line()
-
                                        .style(ui.theme().bar_on_black_style),
-
                                    Constraint::Fill(1),
-
                                ),
-
                                Column::new(
-
                                    Span::raw(" ")
-
                                        .into_right_aligned_line()
-
                                        .cyan()
-
                                        .dim()
-
                                        .reversed(),
-
                                    Constraint::Length(6),
-
                                ),
-
                            ]
-
                            .to_vec(),
-
                            Some(Borders::None),
-
                        );
-

-
                        ui.shortcuts(frame, &[("?", "close")], '∙');
-
                    });
-

-
                    if ui.input_global(|key| key == Key::Char('?')) {
-
                        ui.send_message(Message::PageChanged { page: Page::Main });
-
                    }
-
                    if ui.input_global(|key| key == Key::Esc) {
-
                        ui.send_message(Message::Quit);
-
                    }
-
                }
-
            }
-
            if ui.input_global(|key| key == Key::Ctrl('c')) {
-
                ui.send_message(Message::Quit);
-
            }
-
        });
-

-
        Ok(())
-
    }
-
}
-

-
impl App {
-
    pub fn show_patches(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
-
        let patches = self
-
            .storage
-
            .patches
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .cloned()
-
            .collect::<Vec<_>>();
-
        let mut selected = self.patches.selected();
-

-
        let header = [
-
            Column::new(Span::raw(" ● ").bold(), Constraint::Length(3)),
-
            Column::new(Span::raw("ID").bold(), Constraint::Length(8)),
-
            Column::new(Span::raw("Title").bold(), Constraint::Fill(1)),
-
            Column::new(Span::raw("Author").bold(), Constraint::Length(16)).hide_small(),
-
            Column::new("", Constraint::Length(16)).hide_medium(),
-
            Column::new(Span::raw("Head").bold(), Constraint::Length(8)).hide_small(),
-
            Column::new(Span::raw("+").bold(), Constraint::Length(6)).hide_small(),
-
            Column::new(Span::raw("-").bold(), Constraint::Length(6)).hide_small(),
-
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)).hide_small(),
-
        ];
-

-
        let table = ui.headered_table(frame, &mut selected, &patches, header.clone(), header);
-
        if table.changed {
-
            ui.send_message(Message::PatchesChanged {
-
                state: TableState::new(selected),
-
            });
-
        }
-

-
        // TODO(erikli): Should only work if table has focus
-
        if ui.input_global(|key| key == Key::Char('/')) {
-
            ui.send_message(Message::ShowSearch);
-
        }
-
    }
-

-
    pub fn show_search_text_edit(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
-
        let (mut search_text, mut search_cursor) = (
-
            self.search.clone().read().text,
-
            self.search.clone().read().cursor,
-
        );
-
        let mut search = self.search.clone();
-

-
        let text_edit = ui.text_edit_labeled_singleline(
-
            frame,
-
            &mut search_text,
-
            &mut search_cursor,
-
            "Search".to_string(),
-
            Some(Borders::Spacer { top: 0, left: 0 }),
-
        );
-

-
        if text_edit.changed {
-
            search.write(TextEditState {
-
                text: search_text,
-
                cursor: search_cursor,
-
            });
-
            ui.send_message(Message::UpdateSearch { search });
-
        }
-

-
        if ui.input_global(|key| key == Key::Esc) {
-
            ui.send_message(Message::HideSearch { apply: false });
-
        }
-
        if ui.input_global(|key| key == Key::Char('\n')) {
-
            ui.send_message(Message::HideSearch { apply: true });
-
        }
-
    }
-
}
-

-
impl App {
-
    pub fn selected_patch(&self) -> Option<&PatchItem> {
-
        let patches = self
-
            .storage
-
            .patches
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .collect::<Vec<_>>();
-

-
        self.patches
-
            .selected()
-
            .and_then(|selected| patches.get(selected))
-
            .copied()
-
    }
-
}
-

-
fn browser_context<'a>(ui: &im::Ui<Message>, app: &'a App) -> Vec<Column<'a>> {
-
    let search = app.search.read().text;
-
    let total_count = app.storage.patches.len();
-
    let filtered_count = app
-
        .storage
-
        .patches
-
        .iter()
-
        .filter(|patch| app.filter.matches(patch))
-
        .collect::<Vec<_>>()
-
        .len();
-
    let experimental = false;
-

-
    if experimental {
-
        [
-
            Column::new(
-
                Span::raw(" Search ".to_string()).cyan().dim().reversed(),
-
                Constraint::Length(8),
-
            ),
-
            Column::new(Span::raw("".to_string()), Constraint::Length(1)),
-
            Column::new(
-
                Span::raw(format!(" {} ", search))
-
                    .into_left_aligned_line()
-
                    .cyan()
-
                    .dim()
-
                    .reversed(),
-
                Constraint::Length((search.chars().count() + 2) as u16),
-
            ),
-
            Column::new(Span::raw("".to_string()), Constraint::Fill(1)),
-
            Column::new(
-
                Span::raw(" 0% ")
-
                    .into_right_aligned_line()
-
                    .red()
-
                    .dim()
-
                    .reversed(),
-
                Constraint::Length(6),
-
            ),
-
        ]
-
        .to_vec()
-
    } else {
-
        let filtered_counts = format!(" {filtered_count}/{total_count} ");
-
        let state_counts =
-
            app.storage
-
                .patches
-
                .iter()
-
                .fold((0, 0, 0, 0), |counts, patch| match patch.state {
-
                    radicle::patch::State::Draft => (counts.0 + 1, counts.1, counts.2, counts.3),
-
                    radicle::patch::State::Open { conflicts: _ } => {
-
                        (counts.0, counts.1 + 1, counts.2, counts.3)
-
                    }
-
                    radicle::patch::State::Archived => (counts.0, counts.1, counts.2 + 1, counts.3),
-
                    radicle::patch::State::Merged {
-
                        revision: _,
-
                        commit: _,
-
                    } => (counts.0, counts.1, counts.2, counts.3 + 1),
-
                });
-

-
        if app.filter.is_default() {
-
            let draft = format!(" {} ", state_counts.0);
-
            let open = format!(" {} ", state_counts.1);
-
            let archived = format!(" {} ", state_counts.2);
-
            let merged = format!(" {} ", state_counts.3);
-
            [
-
                Column::new(
-
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
-
                    Constraint::Length(8),
-
                ),
-
                Column::new(
-
                    Span::raw(format!(" {search} "))
-
                        .into_left_aligned_line()
-
                        .style(ui.theme().bar_on_black_style),
-
                    Constraint::Fill(1),
-
                ),
-
                Column::new(
-
                    Span::raw("●")
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim()
-
                        .bold(),
-
                    Constraint::Length(1),
-
                ),
-
                Column::new(
-
                    Span::raw(draft.clone())
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim(),
-
                    Constraint::Length(draft.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    Span::raw("●")
-
                        .style(ui.theme().bar_on_black_style)
-
                        .green()
-
                        .dim()
-
                        .bold(),
-
                    Constraint::Length(1),
-
                ),
-
                Column::new(
-
                    Span::raw(open.clone())
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim(),
-
                    Constraint::Length(open.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    Span::raw("●")
-
                        .style(ui.theme().bar_on_black_style)
-
                        .yellow()
-
                        .dim()
-
                        .bold(),
-
                    Constraint::Length(1),
-
                ),
-
                Column::new(
-
                    Span::raw(archived.clone())
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim(),
-
                    Constraint::Length(archived.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    Span::raw("✔")
-
                        .style(ui.theme().bar_on_black_style)
-
                        .magenta()
-
                        .dim()
-
                        .bold(),
-
                    Constraint::Length(1),
-
                ),
-
                Column::new(
-
                    Span::raw(merged.clone())
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim(),
-
                    Constraint::Length(merged.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    Span::raw(filtered_counts.clone())
-
                        .into_right_aligned_line()
-
                        .cyan()
-
                        .dim()
-
                        .reversed(),
-
                    Constraint::Length(filtered_counts.chars().count() as u16),
-
                ),
-
            ]
-
            .to_vec()
-
        } else {
-
            [
-
                Column::new(
-
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
-
                    Constraint::Length(8),
-
                ),
-
                Column::new(
-
                    Span::raw(format!(" {search} "))
-
                        .into_left_aligned_line()
-
                        .style(ui.theme().bar_on_black_style),
-
                    Constraint::Fill(1),
-
                ),
-
                Column::new(
-
                    Span::raw(filtered_counts.clone())
-
                        .into_right_aligned_line()
-
                        .cyan()
-
                        .dim()
-
                        .reversed(),
-
                    Constraint::Length(filtered_counts.chars().count() as u16),
-
                ),
-
            ]
-
            .to_vec()
-
        }
-
    }
-
}
-

-
fn default_context<'a>(ui: &im::Ui<Message>) -> Vec<Column<'a>> {
-
    [
-
        Column::new(
-
            Span::raw(" ".to_string())
-
                .into_left_aligned_line()
-
                .style(ui.theme().bar_on_black_style),
-
            Constraint::Fill(1),
-
        ),
-
        Column::new(
-
            Span::raw(" 0% ")
-
                .into_right_aligned_line()
-
                .cyan()
-
                .dim()
-
                .reversed(),
-
            Constraint::Length(6),
-
        ),
-
    ]
-
    .to_vec()
-
}
deleted bin/commands/patch/select/rmui.rs
@@ -1,330 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-
use std::vec;
-

-
use ratatui::Frame;
-
use tokio::sync::mpsc::UnboundedSender;
-

-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Text};
-

-
use radicle::patch;
-
use radicle::patch::Status;
-

-
use radicle_tui as tui;
-

-
use tui::ui::rm::widget;
-
use tui::ui::rm::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
-
};
-
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
-
use tui::ui::rm::widget::list::{Table, TableProps};
-
use tui::ui::rm::widget::ViewProps;
-
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
-
use tui::ui::span;
-
use tui::ui::Column;
-

-
use tui::BoxedAny;
-

-
use crate::ui::items::{PatchItem, PatchItemFilter};
-

-
use super::{Message, State};
-

-
type Widget = widget::Widget<State, Message>;
-

-
#[derive(Clone, Default)]
-
pub struct BrowserProps<'a> {
-
    /// Filtered patches.
-
    patches: Vec<PatchItem>,
-
    /// Patch statistics.
-
    stats: HashMap<String, usize>,
-
    /// Header columns
-
    header: Vec<Column<'a>>,
-
    /// Table columns
-
    columns: Vec<Column<'a>>,
-
    /// If search widget should be shown.
-
    show_search: bool,
-
    /// Current search string.
-
    search: String,
-
}
-

-
impl<'a> From<&State> for BrowserProps<'a> {
-
    fn from(state: &State) -> Self {
-
        let mut draft = 0;
-
        let mut open = 0;
-
        let mut archived = 0;
-
        let mut merged = 0;
-

-
        let patches = state.browser.items();
-

-
        for patch in &patches {
-
            match patch.state {
-
                patch::State::Draft => draft += 1,
-
                patch::State::Open { conflicts: _ } => open += 1,
-
                patch::State::Archived => archived += 1,
-
                patch::State::Merged {
-
                    commit: _,
-
                    revision: _,
-
                } => merged += 1,
-
            }
-
        }
-

-
        let stats = HashMap::from([
-
            ("Draft".to_string(), draft),
-
            ("Open".to_string(), open),
-
            ("Archived".to_string(), archived),
-
            ("Merged".to_string(), merged),
-
        ]);
-

-
        Self {
-
            patches,
-
            stats,
-
            header: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(1)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Head", Constraint::Length(8)).hide_small(),
-
                Column::new("+", Constraint::Length(6)).hide_small(),
-
                Column::new("-", Constraint::Length(6)).hide_small(),
-
                Column::new("Updated", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            columns: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(1)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Head", Constraint::Length(8)).hide_small(),
-
                Column::new("+", Constraint::Length(6)).hide_small(),
-
                Column::new("-", Constraint::Length(6)).hide_small(),
-
                Column::new("Updated", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            show_search: state.browser.is_search_shown(),
-
            search: state.browser.read_search(),
-
        }
-
    }
-
}
-

-
pub struct Browser {
-
    /// Patches widget
-
    patches: Widget,
-
    /// Search widget
-
    search: Widget,
-
}
-

-
impl Browser {
-
    pub fn new(tx: UnboundedSender<Message>) -> Self {
-
        Self {
-
            patches: Container::default()
-
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-
                    HeaderProps::default()
-
                        .columns(props.header.clone())
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(
-
                    Table::<State, Message, PatchItem, 9>::default()
-
                        .to_widget(tx.clone())
-
                        .on_event(|_, s, _| {
-
                            let (selected, _) =
-
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::SelectPatch {
-
                                selected: Some(selected),
-
                            })
-
                        })
-
                        .on_update(|state| {
-
                            // TODO: remove and use state directly
-
                            let props = BrowserProps::from(state);
-
                            TableProps::default()
-
                                .columns(props.columns)
-
                                .items(state.browser.items())
-
                                .selected(state.browser.selected())
-
                                .to_boxed_any()
-
                                .into()
-
                        }),
-
                )
-
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-

-
                    FooterProps::default()
-
                        .columns(browser_footer(&props))
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    ContainerProps::default()
-
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
            search: TextField::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    Some(Message::UpdateSearch {
-
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.read_search())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        }
-
    }
-
}
-

-
impl View for Browser {
-
    type Message = Message;
-
    type State = State;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            match key {
-
                Key::Esc => {
-
                    self.search.reset();
-
                    Some(Message::CloseSearch)
-
                }
-
                Key::Char('\n') => Some(Message::ApplySearch),
-
                _ => {
-
                    self.search.handle_event(key);
-
                    None
-
                }
-
            }
-
        } else {
-
            match key {
-
                Key::Char('/') => Some(Message::OpenSearch),
-
                _ => {
-
                    self.patches.handle_event(key);
-
                    None
-
                }
-
            }
-
        }
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        self.patches.update(state);
-
        self.search.update(state);
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
-
            let [_, search_area, _] = Layout::horizontal([
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
                Constraint::Length(1),
-
            ])
-
            .areas(search_area);
-

-
            self.patches.render(RenderProps::from(table_area), frame);
-
            self.search
-
                .render(RenderProps::from(search_area).focus(render.focus), frame);
-
        } else {
-
            self.patches.render(render, frame);
-
        }
-
    }
-
}
-

-
fn browser_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
-
    let filter = PatchItemFilter::from_str(&props.search).unwrap_or_default();
-

-
    let search = Line::from(vec![
-
        span::default(" Search ").cyan().dim().reversed(),
-
        span::default(" "),
-
        span::default(&props.search.to_string()).gray().dim(),
-
    ]);
-

-
    let draft = Line::from(vec![
-
        span::default(&props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Draft").dim(),
-
    ]);
-

-
    let open = Line::from(vec![
-
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Open").dim(),
-
    ]);
-

-
    let merged = Line::from(vec![
-
        span::default(&props.stats.get("Merged").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Merged").dim(),
-
    ]);
-

-
    let archived = Line::from(vec![
-
        span::default(&props.stats.get("Archived").unwrap_or(&0).to_string())
-
            .yellow()
-
            .dim(),
-
        span::default(" Archived").dim(),
-
    ]);
-

-
    let sum = Line::from(vec![
-
        span::default("Σ ").dim(),
-
        span::default(&props.patches.len().to_string()).dim(),
-
    ]);
-

-
    match filter.status() {
-
        Some(state) => {
-
            let block = match state {
-
                Status::Draft => draft,
-
                Status::Open => open,
-
                Status::Merged => merged,
-
                Status::Archived => archived,
-
            };
-

-
            vec![
-
                Column::new(Text::from(search), Constraint::Fill(1)),
-
                Column::new(
-
                    Text::from(block.clone()),
-
                    Constraint::Min(block.width() as u16),
-
                ),
-
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
            ]
-
        }
-
        None => vec![
-
            Column::new(Text::from(search), Constraint::Fill(1)),
-
            Column::new(
-
                Text::from(draft.clone()),
-
                Constraint::Min(draft.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(open.clone()),
-
                Constraint::Min(open.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(merged.clone()),
-
                Constraint::Min(merged.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(archived.clone()),
-
                Constraint::Min(archived.width() as u16),
-
            ),
-
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
        ],
-
    }
-
}
modified bin/main.rs
@@ -3,6 +3,7 @@ mod commands;
mod git;
mod log;
mod settings;
+
mod terminal;
#[cfg(test)]
mod test;
mod ui;
@@ -12,14 +13,12 @@ use std::io;
use std::io::Write;
use std::{iter, process};

-
use anyhow::anyhow;
-

use radicle::version::Version;

-
use radicle_cli::terminal;
-
use radicle_term as term;
+
use radicle_cli::terminal as cli_term;

use commands::*;
+
use terminal as term;

pub const NAME: &str = "rad-tui";
pub const DESCRIPTION: &str = "Radicle terminal interfaces";
@@ -34,18 +33,25 @@ pub const VERSION: Version = Version {
};

#[derive(Debug)]
-
enum Command {
+
enum CommandName {
    Other(Vec<OsString>),
    Help,
    Version,
}

+
#[derive(Debug)]
+
enum Command {
+
    Other(Vec<OsString>),
+
    Help,
+
    Version { json: bool },
+
}
+

fn main() {
    match parse_args().map_err(Some).and_then(run) {
        Ok(_) => process::exit(0),
        Err(err) => {
            if let Some(err) = err {
-
                term::error(format!("rad: {err}"));
+
                radicle_term::error(format!("rad-tui: {err}"));
            }
            process::exit(1);
        }
@@ -57,45 +63,87 @@ fn parse_args() -> anyhow::Result<Command> {

    let mut parser = lexopt::Parser::from_env();
    let mut command = None;
+
    let mut forward = true;
+
    let mut json = false;

    while let Some(arg) = parser.next()? {
        match arg {
+
            Long("no-forward") => {
+
                forward = false;
+
            }
+
            Long("json") => {
+
                json = true;
+
            }
            Long("help") | Short('h') => {
-
                command = Some(Command::Help);
+
                command = Some(CommandName::Help);
            }
            Long("version") => {
-
                command = Some(Command::Version);
+
                command = Some(CommandName::Version);
            }
            Value(val) if command.is_none() => {
-
                let args = iter::once(val)
-
                    .chain(iter::from_fn(|| parser.value().ok()))
-
                    .collect();
+
                command = match val.to_string_lossy().as_ref() {
+
                    "help" => Some(CommandName::Help),
+
                    "version" => Some(CommandName::Version),
+
                    _ => {
+
                        let args = iter::once(val)
+
                            .chain(iter::from_fn(|| parser.value().ok()))
+
                            .collect();

-
                command = Some(Command::Other(args))
+
                        Some(CommandName::Other(args))
+
                    }
+
                }
            }
            _ => return Err(anyhow::anyhow!(arg.unexpected())),
        }
    }

-
    Ok(command.unwrap_or_else(|| Command::Other(vec![])))
+
    let command = match command {
+
        Some(CommandName::Help) => {
+
            if forward {
+
                Command::Other(vec!["help".into()])
+
            } else {
+
                Command::Help
+
            }
+
        }
+
        Some(CommandName::Version) => {
+
            if forward {
+
                Command::Other(vec!["version".into()])
+
            } else {
+
                Command::Version { json }
+
            }
+
        }
+
        Some(CommandName::Other(args)) => Command::Other(args),
+
        _ => {
+
            if forward {
+
                Command::Other(vec!["help".into()])
+
            } else {
+
                Command::Other(vec![])
+
            }
+
        }
+
    };
+

+
    Ok(command)
}

fn print_help() -> anyhow::Result<()> {
-
    VERSION.write(&mut io::stdout())?;
    println!("{DESCRIPTION}");
    println!();

-
    tui_help::run(Default::default(), terminal::DefaultContext)
+
    tui_help::run(Default::default(), cli_term::DefaultContext)
}

fn run(command: Command) -> Result<(), Option<anyhow::Error>> {
    match command {
-
        Command::Version => {
+
        Command::Version { json } => {
            let mut stdout = io::stdout();
-
            VERSION
-
                .write_json(&mut stdout)
-
                .map_err(|e| Some(e.into()))?;
-
            writeln!(&mut stdout).ok();
+
            if json {
+
                VERSION
+
                    .write_json(&mut stdout)
+
                    .map_err(|e| Some(e.into()))?;
+
                writeln!(&mut stdout).ok();
+
            } else {
+
                println!("rad-tui {} ({})", VERSION.version, VERSION.commit);
+
            }
        }
        Command::Help => {
            print_help()?;
@@ -114,31 +162,133 @@ fn run(command: Command) -> Result<(), Option<anyhow::Error>> {
    Ok(())
}

-
fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
-
    match exe {
+
fn run_other(command: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
+
    match command {
        "issue" => {
-
            terminal::run_command_args::<tui_issue::Options, _>(
+
            term::run_command_args::<tui_issue::Options, _>(
                tui_issue::HELP,
                tui_issue::run,
                args.to_vec(),
            );
        }
        "patch" => {
-
            terminal::run_command_args::<tui_patch::Options, _>(
+
            term::run_command_args::<tui_patch::Options, _>(
                tui_patch::HELP,
                tui_patch::run,
                args.to_vec(),
            );
        }
        "inbox" => {
-
            terminal::run_command_args::<tui_inbox::Options, _>(
+
            term::run_command_args::<tui_inbox::Options, _>(
                tui_inbox::HELP,
                tui_inbox::run,
                args.to_vec(),
            );
        }
-
        other => Err(Some(anyhow!(
-
            "`rad-tui {other}` is not a command. See `rad-tui --help` for a list of commands.",
-
        ))),
+
        command => term::run_rad(command, args),
+
    }
+
}
+

+
#[cfg(test)]
+
mod cli {
+
    use assert_cmd::prelude::*;
+
    use predicates::prelude::*;
+
    use std::process::Command;
+

+
    mod assert {
+
        use predicates::prelude::*;
+
        use predicates::str::ContainsPredicate;
+

+
        pub fn is_rad_manual() -> ContainsPredicate {
+
            predicate::str::contains("Radicle CLI Manual")
+
        }
+

+
        pub fn is_help() -> ContainsPredicate {
+
            predicate::str::contains("Radicle terminal interfaces")
+
        }
+
    }
+

+
    #[test]
+
    fn can_be_executed() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.assert().success();
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_command_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.assert().success().stdout(assert::is_rad_manual());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_command_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("--no-forward");
+
        cmd.assert().success().stdout(assert::is_help());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn version_command_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("version");
+
        cmd.assert()
+
            .success()
+
            .stdout(predicate::str::starts_with("rad "));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn version_command_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("version").arg("--no-forward");
+
        cmd.assert()
+
            .success()
+
            .stdout(predicate::str::starts_with("rad-tui "));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn version_command_prints_json() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("version").arg("--no-forward").arg("--json");
+
        cmd.assert()
+
            .success()
+
            .stdout(predicate::str::contains("\"name\":\"rad-tui\""));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn help_command_is_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("help");
+
        cmd.assert().success().stdout(assert::is_rad_manual());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn help_command_is_not_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let mut cmd = Command::cargo_bin("rad-tui")?;
+

+
        cmd.arg("help").arg("--no-forward");
+
        cmd.assert().success().stdout(assert::is_help());
+

+
        Ok(())
    }
}
added bin/terminal.rs
@@ -0,0 +1,95 @@
+
use std::ffi::OsString;
+
use std::io::ErrorKind;
+
use std::process;
+

+
use anyhow::anyhow;
+

+
use radicle_cli::terminal;
+
use radicle_cli::terminal::args;
+
use radicle_cli::terminal::io;
+
use radicle_cli::terminal::{Args, Command, DefaultContext, Error, Help};
+

+
fn _run_rad(args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
+
    let status = process::Command::new("rad").args(args).status();
+

+
    match status {
+
        Ok(status) => {
+
            if !status.success() {
+
                return Err(None);
+
            }
+
        }
+
        Err(err) => {
+
            if let ErrorKind::NotFound = err.kind() {
+
                return Err(Some(anyhow!("'rad' was not found.",)));
+
            } else {
+
                return Err(Some(err.into()));
+
            }
+
        }
+
    }
+

+
    Ok(())
+
}
+

+
pub fn run_rad(command: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
+
    let args = [vec![command.into()], args.to_vec()].concat();
+

+
    _run_rad(&args)
+
}
+

+
pub fn run_command_args<A, C>(help: Help, cmd: C, args: Vec<OsString>) -> !
+
where
+
    A: Args,
+
    C: Command<A, DefaultContext>,
+
{
+
    use io as term;
+

+
    let options = match A::from_args(args) {
+
        Ok((opts, unparsed)) => {
+
            if let Err(err) = args::finish(unparsed) {
+
                term::error(err);
+
                process::exit(1);
+
            }
+
            opts
+
        }
+
        Err(err) => {
+
            let hint = match err.downcast_ref::<Error>() {
+
                Some(Error::Help) => {
+
                    help.print();
+
                    process::exit(0);
+
                }
+
                // Print the manual, or the regular help if there's an error.
+
                Some(Error::HelpManual { name }) => {
+
                    let Ok(status) = term::manual(name) else {
+
                        help.print();
+
                        process::exit(0);
+
                    };
+
                    if !status.success() {
+
                        help.print();
+
                        process::exit(0);
+
                    }
+
                    process::exit(status.code().unwrap_or(0));
+
                }
+
                Some(Error::Usage) => {
+
                    term::usage(help.name, help.usage);
+
                    process::exit(1);
+
                }
+
                Some(Error::WithHint { hint, .. }) => Some(hint),
+
                None => None,
+
            };
+
            io::error(format!("rad-tui {}: {err}", help.name));
+

+
            if let Some(hint) = hint {
+
                io::hint(hint);
+
            }
+
            process::exit(1);
+
        }
+
    };
+

+
    match cmd.run(options, DefaultContext) {
+
        Ok(()) => process::exit(0),
+
        Err(err) => {
+
            terminal::fail(help.name, &err);
+
            process::exit(1);
+
        }
+
    }
+
}
deleted rad.sh
@@ -1,45 +0,0 @@
-
#!/bin/bash
-
extract_operation() {
-
    local op=$(echo $1 | jq '.operation')
-
    op=${op//\"/""}
-
    
-
    echo "$op"
-
}
-

-
extract_id() {
-
    local ids=$(echo $1 | jq '.ids')
-
    local id=$(echo $ids | jq '.[0]')
-
    id=${id//\"/""}
-
    
-
    echo "$id"
-
}
-

-
if [[ "$1" == "patch" ]] || [[ "$1" == "issue" ]] || [[ "$1" == "inbox" ]]; then
-
    if [[ -n "$2" ]]; then
-
        if [[ "$2" == "--tui" ]]; then
-
            # Run TUI
-
            { out=$(rad-tui $1 select 2>&1 >&3 3>&-); } 3>&1
-
            if [[ "$out" == "" ]]; then
-
                exit 1
-
            fi
-
            
-
            op=$(extract_operation $out)
-
            id=$(extract_id $out)
-
            
-
            rad $1 $op $id
-
        else
-
            # Run TUI
-
            args="--mode id"
-
            { out=$(rad-tui $1 select $args 2>&1 >&3 3>&-); } 3>&1
-
            id=$(extract_id $out)
-
            
-
            args=("$@")
-
 
-
            rad $1 $2 $id ${args[@]:2}
-
        fi
-
    else
-
        rad $@
-
    fi
-
else
-
    rad $@
-
fi

\ No newline at end of file
modified src/terminal.rs
@@ -34,6 +34,8 @@ impl Terminal {
                let area = inner.get_frame().area();
                let position = Position::new(area.x, area.y);
                inner.set_cursor_position(position)?;
+

+
                inner.clear()?;
            }
        }