Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin: Rename `commands` to `apps`
Erik Kundt committed 3 months ago
commit bb0ed113d9e3a248f881a4e2af685cc5882f9710
parent 4e2f75d
22 files changed +6356 -6356
modified README.md
@@ -85,13 +85,13 @@ home.packages = [

### Usage

-
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.
+
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 app, 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`:
+
runs the patch list app 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
@@ -114,13 +114,13 @@ alias rad="rad-tui"

#### CLI integration via JSON

-
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.
+
The apps 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.

```
rad-tui patch list --json
```

-
runs the patch list interface and return a JSON object specifying the operation and id selected:
+
runs the patch list app and return a JSON object specifying the operation and id selected:

```
{ "operation": "show", "ids": ["546443226b300484a97a2b2d7c7000af6e8169ba"], args:[] }
added bin/apps.rs
@@ -0,0 +1,8 @@
+
#[path = "apps/help.rs"]
+
pub mod tui_help;
+
#[path = "apps/inbox.rs"]
+
pub mod tui_inbox;
+
#[path = "apps/issue.rs"]
+
pub mod tui_issue;
+
#[path = "apps/patch.rs"]
+
pub mod tui_patch;
added bin/apps/help.rs
@@ -0,0 +1,100 @@
+
use std::ffi::OsString;
+

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

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

+
use super::*;
+

+
pub const HELP: Help = Help {
+
    name: "help",
+
    description: "Print help",
+
    version: env!("CARGO_PKG_VERSION"),
+
    usage: "Usage: rad-tui help [--help]",
+
};
+

+
const COMMANDS: &[Help] = &[tui_help::HELP];
+

+
#[derive(Default)]
+
pub struct Options {}
+

+
impl Args for Options {
+
    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<()> {
+
    println!(
+
        "{} {}",
+
        term::format::secondary("Usage:").bold(),
+
        term::format::tertiary("rad-tui [COMMAND] [OPTIONS]"),
+
    );
+

+
    if let Err(e) = ctx.profile() {
+
        term::blank();
+
        match e.downcast_ref() {
+
            Some(Error::WithHint { err, hint }) => {
+
                term::print(term::format::yellow(err));
+
                term::print(term::format::yellow(hint));
+
            }
+
            Some(e) => {
+
                term::error(e);
+
            }
+
            None => {
+
                term::error(e);
+
            }
+
        }
+
        term::blank();
+
    }
+

+
    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::tertiary(format!("{:-16}", help.name)),
+
            term::format::default(help.description)
+
        );
+
    }
+

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

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

+
use std::ffi::OsString;
+

+
use anyhow::anyhow;
+

+
use radicle::node::notifications::NotificationId;
+
use radicle::storage::{HasRepoId, ReadRepository};
+

+
use radicle_cli::terminal::{Args, Error, Help};
+

+
use crate::terminal;
+
use crate::ui::items::notification::filter::SortBy;
+

+
use self::list::{InboxOperation, RepositoryMode};
+

+
pub const HELP: Help = Help {
+
    name: "inbox",
+
    description: "Terminal interfaces for notifications",
+
    version: env!("CARGO_PKG_VERSION"),
+
    usage: r#"
+
Usage
+

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

+
List options
+

+
    --sort-by <field>       Sort by `id` or `timestamp` (default: timestamp)
+
    --reverse, -r           Reverse the list
+

+
    --json                  Return JSON on stderr instead of calling `rad`
+

+
Other options
+

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

+
#[derive(Debug, PartialEq)]
+
pub struct Options {
+
    op: Operation,
+
}
+

+
#[derive(Debug, PartialEq)]
+
pub enum Operation {
+
    List { opts: ListOptions },
+
    Other { args: Vec<OsString> },
+
    Unknown { args: Vec<OsString> },
+
}
+

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

+
#[derive(Debug, Default, Clone, PartialEq)]
+
pub struct ListOptions {
+
    mode: RepositoryMode,
+
    sort_by: 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.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 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') => {
+
                    help = true;
+
                    // Only enable forwarding if it was not already disabled explicitly
+
                    forward = match forward {
+
                        Some(false) => Some(false),
+
                        _ => Some(true),
+
                    };
+
                }
+

+
                Long("reverse") | Short('r') => {
+
                    reverse = Some(true);
+
                }
+
                Long("sort-by") => {
+
                    use radicle_cli::terminal;
+

+
                    let val = parser.value()?;
+
                    match terminal::args::string(&val).as_str() {
+
                        "timestamp" => field = Some("timestamp"),
+
                        "id" => field = Some("id"),
+
                        other => anyhow::bail!("unknown sorting field '{other}'"),
+
                    }
+
                }
+

+
                Long("repo") if repository_mode.is_none() => {
+
                    use radicle_cli::terminal;
+

+
                    let val = parser.value()?;
+
                    let repo = terminal::args::rid(&val)?;
+
                    repository_mode = Some(RepositoryMode::ByRepo((repo, None)));
+
                }
+
                Long("all") | Short('a') if repository_mode.is_none() => {
+
                    repository_mode = Some(RepositoryMode::All);
+
                }
+

+
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
+
                    "list" => op = OperationName::List,
+
                    _ => {
+
                        op = OperationName::Unknown;
+
                        // Only enable forwarding if it was not already disabled explicitly
+
                        forward = match forward {
+
                            Some(false) => Some(false),
+
                            _ => Some(true),
+
                        };
+
                    }
+
                },
+
                _ => {
+
                    if op == OperationName::List {
+
                        return Err(anyhow!(arg.unexpected()));
+
                    }
+
                }
+
            }
+
        }
+

+
        // 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 = repository_mode.unwrap_or_default();
+
        list_opts.sort_by = if let Some(field) = field {
+
            SortBy {
+
                field,
+
                reverse: reverse.unwrap_or(false),
+
            }
+
        } else {
+
            SortBy::default()
+
        };
+

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

+
        Ok((Options { op }, vec![]))
+
    }
+
}
+

+
#[tokio::main]
+
pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) -> anyhow::Result<()> {
+
    use radicle::storage::ReadStorage;
+

+
    let (_, rid) = radicle::rad::cwd()
+
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+

+
    match options.op {
+
        Operation::List { opts } => {
+
            #[derive(Default)]
+
            struct PreviousState {
+
                notif_id: Option<NotificationId>,
+
                search: Option<String>,
+
            }
+

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

+
            let mut state = PreviousState::default();
+
            loop {
+
                let profile = ctx.profile()?;
+
                let repository = profile.storage.repository(rid)?;
+

+
                let context = list::Context {
+
                    profile,
+
                    project: repository.identity_doc()?.project()?,
+
                    rid: repository.rid(),
+
                    mode: opts.mode.clone(),
+
                    search: state.search.clone(),
+
                    sort_by: opts.sort_by,
+
                    _notif_id: state.notif_id,
+
                };
+

+
                let app = list::Tui::new(context);
+
                let selection = app.run().await?;
+

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

+
                    log::info!("Exiting inbox listing app..");
+
                    eprint!("{selection}");
+
                } else if let Some(selection) = selection {
+
                    if let Some(operation) = selection.operation.clone() {
+
                        match operation {
+
                            InboxOperation::Show { id, search } => {
+
                                state = PreviousState {
+
                                    notif_id: Some(id),
+
                                    search: Some(search),
+
                                };
+
                                terminal::run_rad(
+
                                    Some("inbox"),
+
                                    &["show".into(), id.to_string().into()],
+
                                )?;
+
                            }
+
                            InboxOperation::Clear { id, search } => {
+
                                state = PreviousState {
+
                                    notif_id: Some(id),
+
                                    search: Some(search),
+
                                };
+
                                terminal::run_rad(
+
                                    Some("inbox"),
+
                                    &["clear".into(), id.to_string().into()],
+
                                )?;
+
                            }
+
                        }
+
                    }
+
                } else {
+
                    break;
+
                }
+
            }
+
        }
+
        Operation::Other { args } => {
+
            terminal::run_rad(Some("inbox"), &args)?;
+
        }
+
        Operation::Unknown { .. } => {
+
            anyhow::bail!("unknown operation provided");
+
        }
+
    }
+

+
    Ok(())
+
}
+

+
#[cfg(test)]
+
mod cli {
+
    use radicle_cli::terminal::args::Error;
+
    use radicle_cli::terminal::Args;
+

+
    use super::{ListOptions, Operation, Options};
+

+
    #[test]
+
    fn empty_operation_should_default_to_list_and_not_be_forwarded(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec![];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["--help".into(), "--no-forward".into()];
+

+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+
        assert!(matches!(actual, Error::Help));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec!["--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec!["list".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_should_not_be_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec!["list".into(), "--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["list".into(), "--help".into(), "--no-forward".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+

+
        assert!(matches!(actual, Error::Help));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_should_not_be_forwarded_reversed(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--no-forward".into(), "--help".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+

+
        assert!(matches!(actual, Error::Help));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into()];
+
        let expected_op = Operation::Other { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into(), "--no-forward".into()];
+
        let expected_op = Operation::Unknown { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+
}
added bin/apps/inbox/list.rs
@@ -0,0 +1,856 @@
+
use std::collections::hash_map::Entry;
+
use std::collections::HashMap;
+
use std::str::FromStr;
+
use std::sync::{Arc, Mutex};
+
use std::vec;
+

+
use anyhow::Result;
+

+
use ratatui::widgets::Clear;
+
use serde::Serialize;
+

+
use radicle::node::notifications::NotificationId;
+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::prelude::*;
+
use ratatui::text::Span;
+
use ratatui::{Frame, Viewport};
+

+
use radicle::identity::Project;
+
use radicle::prelude::RepoId;
+
use radicle::storage::ReadStorage;
+
use radicle::Profile;
+

+
use radicle_tui as tui;
+

+
use tui::event::Key;
+
use tui::store;
+
use tui::task::{Process, Task};
+
use tui::ui;
+
use tui::ui::layout::Spacing;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::{
+
    Borders, Column, ContainerState, TableState, TextEditState, TextViewState, Window,
+
};
+
use tui::ui::{BufferedValue, Show, Ui};
+
use tui::{Channel, Exit};
+

+
use crate::settings;
+
use crate::ui::items::filter::Filter;
+
use crate::ui::items::notification::filter::{NotificationFilter, SortBy};
+
use crate::ui::items::notification::{Notification, NotificationKind};
+

+
#[derive(Clone, Default, Debug, PartialEq, Eq)]
+
pub enum RepositoryMode {
+
    #[default]
+
    Contextual,
+
    All,
+
    ByRepo((RepoId, Option<String>)),
+
}
+

+
/// The selected issue operation returned by the operation
+
/// selection widget.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub enum InboxOperation {
+
    Show { id: NotificationId, search: String },
+
    Clear { id: NotificationId, search: String },
+
}
+

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

+
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`:      Cancel
+
`q`:        Quit
+

+
# Specific keybindings
+

+
`enter`:    Show notification
+
`r`:        Reload notifications
+
`c`:        Clear notification
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Examples:   state=unseen kind=cob bugfix
+
            kind=(cob:xyz.radicle.issue or cob:xyz.radicle.issue)
+
            state=unseen author=(did:key:... or did:key:...)"#;
+

+
#[derive(Clone, Debug)]
+
pub struct Context {
+
    pub profile: Profile,
+
    pub project: Project,
+
    pub rid: RepoId,
+
    pub mode: RepositoryMode,
+
    pub sort_by: SortBy,
+
    pub _notif_id: Option<NotificationId>,
+
    pub search: Option<String>,
+
}
+

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

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

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let viewport = Viewport::Inline(20);
+
        let channel = Channel::default();
+
        let state = App::try_from(&self.context)?;
+

+
        tui::im(
+
            state,
+
            viewport,
+
            channel,
+
            vec![Loader::new(self.context.clone())],
+
        )
+
        .await
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Change {
+
    Page {
+
        page: Page,
+
    },
+
    MainGroup {
+
        state: ContainerState,
+
    },
+
    Patches {
+
        state: TableState,
+
    },
+
    Search {
+
        search: BufferedValue<TextEditState>,
+
    },
+
    Help {
+
        state: TextViewState,
+
    },
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Initialize,
+
    Changed(Change),
+
    ShowSearch,
+
    HideSearch { apply: bool },
+
    Reload,
+
    Loaded(Vec<Notification>),
+
    Exit { operation: Option<InboxOperation> },
+
    Quit,
+
}
+

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

+
#[derive(Clone, Debug)]
+
pub struct AppState {
+
    page: Page,
+
    main_group: ContainerState,
+
    patches: TableState,
+
    search: BufferedValue<TextEditState>,
+
    show_search: bool,
+
    help: TextViewState,
+
    filter: NotificationFilter,
+
    loading: bool,
+
    initialized: bool,
+
    theme: Theme,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct App {
+
    context: Arc<Mutex<Context>>,
+
    notifications: Arc<Mutex<Vec<Notification>>>,
+
    state: AppState,
+
}
+

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

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

+
        let search = context.search.as_ref().map(|s| s.trim().to_string());
+
        let (search, filter) = match search {
+
            Some(search) => (
+
                search.clone(),
+
                NotificationFilter::from_str(search.trim()).unwrap_or(NotificationFilter::Invalid),
+
            ),
+
            None => {
+
                let filter = NotificationFilter::default();
+
                (filter.to_string().trim().to_string(), filter)
+
            }
+
        };
+

+
        Ok(App {
+
            context: Arc::new(Mutex::new(context.clone())),
+
            notifications: Arc::new(Mutex::new(vec![])),
+
            state: AppState {
+
                page: Page::Main,
+
                main_group: ContainerState::new(3, Some(0)),
+
                patches: TableState::new(Some(0)),
+
                search: BufferedValue::new(TextEditState {
+
                    text: search.to_string(),
+
                    cursor: search.chars().count(),
+
                }),
+
                show_search: false,
+
                help: TextViewState::new(Position::default()),
+
                filter,
+
                loading: false,
+
                initialized: false,
+
                theme,
+
            },
+
        })
+
    }
+
}
+

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

+
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
+
        match message {
+
            Message::Initialize => {
+
                self.state.loading = true;
+
                self.state.initialized = true;
+
                None
+
            }
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => Some(Exit {
+
                value: Some(Selection {
+
                    operation,
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::ShowSearch => {
+
                self.state.main_group = ContainerState::new(3, None);
+
                self.state.show_search = true;
+
                None
+
            }
+
            Message::HideSearch { apply } => {
+
                self.state.main_group = ContainerState::new(3, Some(0));
+
                self.state.show_search = false;
+

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

+
                self.state.filter = NotificationFilter::from_str(&self.state.search.read().text)
+
                    .unwrap_or(NotificationFilter::Invalid);
+

+
                None
+
            }
+
            Message::Reload => {
+
                self.state.loading = true;
+
                None
+
            }
+
            Message::Loaded(notifications) => {
+
                self.apply_notifications(notifications);
+
                self.apply_sorting();
+
                self.state.loading = false;
+
                None
+
            }
+
            Message::Changed(changed) => match changed {
+
                Change::Page { page } => {
+
                    self.state.page = page;
+
                    None
+
                }
+
                Change::MainGroup { state } => {
+
                    self.state.main_group = state;
+
                    None
+
                }
+
                Change::Patches { state } => {
+
                    self.state.patches = state;
+
                    None
+
                }
+
                Change::Search { search } => {
+
                    self.state.search = search;
+
                    self.state.filter =
+
                        NotificationFilter::from_str(&self.state.search.read().text)
+
                            .unwrap_or(NotificationFilter::Invalid);
+
                    self.state.patches.select_first();
+
                    None
+
                }
+
                Change::Help { state } => {
+
                    self.state.help = state;
+
                    None
+
                }
+
            },
+
        }
+
    }
+
}
+

+
impl Show<Message> for App {
+
    fn show(&self, ctx: &ui::Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, self.state.theme.clone(), |ui| {
+
            // Initialize
+
            if !self.state.initialized {
+
                ui.send_message(Message::Initialize);
+
            }
+

+
            match self.state.page {
+
                Page::Main => {
+
                    let show_search = self.state.show_search;
+
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
+

+
                    ui.container(
+
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
+
                        &mut page_focus,
+
                        |ui| {
+
                            let mut group_focus = self.state.main_group.focus();
+

+
                            let group = ui.container(
+
                                ui::Layout::Expandable3 { left_only: true },
+
                                &mut group_focus,
+
                                |ui| {
+
                                    self.show_browser(frame, ui);
+
                                },
+
                            );
+
                            if group.response.changed {
+
                                ui.send_message(Message::Changed(Change::MainGroup {
+
                                    state: ContainerState::new(3, group_focus),
+
                                }));
+
                            }
+

+
                            if show_search {
+
                                self.show_browser_search(frame, ui);
+
                            } else if let Some(0) = group_focus {
+
                                self.show_browser_footer(frame, ui);
+
                            }
+
                        },
+
                    );
+

+
                    if !show_search && ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
+
                    }
+
                }
+

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

+
                    ui.container(layout, &mut Some(1), |ui| {
+
                        self.show_help_text(frame, ui);
+
                        self.show_help_context(frame, ui);
+

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

+
                    if ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page { page: Page::Main }));
+
                    }
+
                }
+
            }
+
            if ui.has_input(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
            if ui.has_input(|key| key == Key::Ctrl('c')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
+
}
+

+
impl App {
+
    pub fn show_browser(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        let context = self.context.lock().unwrap();
+
        let notifs = self.notifications.lock().unwrap();
+
        let notifs = notifs
+
            .iter()
+
            .filter(|notif| self.state.filter.matches(notif))
+
            .cloned()
+
            .collect::<Vec<_>>();
+
        let mut selected = self.state.patches.selected();
+

+
        let header = [
+
            Column::new(Span::raw(" ● ").bold(), Constraint::Length(3)),
+
            Column::new(Span::raw("ID").bold(), Constraint::Length(8)).hide_medium(),
+
            Column::new(Span::raw("Summary").bold(), Constraint::Fill(1)),
+
            Column::new(Span::raw("Repository").bold(), Constraint::Length(16))
+
                .skip(context.mode != RepositoryMode::All),
+
            Column::new(Span::raw("OID").bold(), Constraint::Length(8)).hide_medium(),
+
            Column::new(Span::raw("Kind").bold(), Constraint::Length(20)).hide_small(),
+
            Column::new(Span::raw("Change").bold(), Constraint::Length(8)).hide_small(),
+
            Column::new(Span::raw("Author").bold(), Constraint::Length(16)).hide_medium(),
+
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)),
+
        ];
+

+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.column_bar(frame, header.to_vec(), Spacing::from(1), Some(Borders::Top));
+

+
                let table = ui.table(
+
                    frame,
+
                    &mut selected,
+
                    &notifs,
+
                    header.to_vec(),
+
                    Some("".into()),
+
                    Spacing::from(1),
+
                    Some(Borders::BottomSides),
+
                );
+
                if table.changed {
+
                    ui.send_message(Message::Changed(Change::Patches {
+
                        state: TableState::new(selected),
+
                    }));
+
                }
+

+
                if self.state.loading {
+
                    self.show_loading_popup(frame, ui);
+
                }
+

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

+
        if ui.has_input(|key| key == Key::Char('r')) {
+
            ui.send_message(Message::Reload);
+
        }
+

+
        if let Some(notification) = selected.and_then(|s| notifs.get(s)) {
+
            if ui.has_input(|key| key == Key::Enter) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(InboxOperation::Show {
+
                        id: notification.id,
+
                        search: self.state.search.read().text,
+
                    }),
+
                });
+
            }
+
            if ui.has_input(|key| key == Key::Char('c')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(InboxOperation::Clear {
+
                        id: notification.id,
+
                        search: self.state.search.read().text,
+
                    }),
+
                });
+
            }
+
        }
+
    }
+

+
    fn show_browser_footer(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.layout(Layout::vertical([3, 1]), None, |ui| {
+
            self.show_browser_context(frame, ui);
+
            self.show_browser_shortcuts(frame, ui);
+
        });
+
    }
+

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

+
        let text_edit = ui.text_edit_singleline(
+
            frame,
+
            &mut search_text,
+
            &mut search_cursor,
+
            Some("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::Changed(Change::Search { search }));
+
        }
+

+
        if ui.has_input(|key| key == Key::Esc) {
+
            ui.send_message(Message::HideSearch { apply: false });
+
        }
+
        if ui.has_input(|key| key == Key::Enter) {
+
            ui.send_message(Message::HideSearch { apply: true });
+
        }
+
    }
+

+
    fn show_browser_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        let context = {
+
            let notifs = self.notifications.lock().unwrap();
+
            let search = self.state.search.read().text;
+
            let total_count = notifs.len();
+
            let filtered_count = notifs
+
                .iter()
+
                .filter(|patch| self.state.filter.matches(patch))
+
                .collect::<Vec<_>>()
+
                .len();
+

+
            let filtered_counts = format!(" {filtered_count}/{total_count} ");
+
            let seen_counts = notifs
+
                .iter()
+
                .fold((0, 0), |counts, notif| match notif.seen {
+
                    true => (counts.0 + 1, counts.1),
+
                    false => (counts.0, counts.1 + 1),
+
                });
+

+
            if self.state.filter.is_default() {
+
                let seen = format!(" {} ", seen_counts.0);
+
                let unseen = format!(" {} ", seen_counts.1);
+
                [
+
                    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)
+
                            .cyan()
+
                            .dim(),
+
                        Constraint::Fill(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw("●")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .gray()
+
                            .dim()
+
                            .bold(),
+
                        Constraint::Length(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw(seen.clone())
+
                            .style(ui.theme().bar_on_black_style)
+
                            .dim(),
+
                        Constraint::Length(seen.chars().count() as u16),
+
                    ),
+
                    Column::new(
+
                        Span::raw("●")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .cyan()
+
                            .dim()
+
                            .bold(),
+
                        Constraint::Length(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw(unseen.clone())
+
                            .style(ui.theme().bar_on_black_style)
+
                            .dim(),
+
                        Constraint::Length(unseen.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)
+
                            .cyan(),
+
                        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()
+
            }
+
        };
+

+
        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
+
    }
+

+
    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.shortcuts(
+
            frame,
+
            &[
+
                ("enter", "show"),
+
                ("r", "reload"),
+
                ("c", "clear"),
+
                ("/", "search"),
+
                ("?", "help"),
+
            ],
+
            '∙',
+
            Alignment::Left,
+
        );
+
    }
+

+
    fn show_loading_popup(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.popup(Layout::vertical([Constraint::Min(1)]), |ui| {
+
            ui.layout(
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).margin(1),
+
                None,
+
                |ui| {
+
                    ui.label(frame, "");
+
                    ui.layout(
+
                        Layout::horizontal([Constraint::Min(1), Constraint::Length(11)]),
+
                        None,
+
                        |ui| {
+
                            ui.label(frame, "");
+
                            ui.layout(Layout::vertical([Constraint::Min(1)]), None, |ui| {
+
                                frame.render_widget(Clear, ui.area());
+
                                ui.column_bar(
+
                                    frame,
+
                                    [Column::new(
+
                                        Span::raw(" Loading ").magenta().rapid_blink(),
+
                                        Constraint::Fill(1),
+
                                    )]
+
                                    .to_vec(),
+
                                    Spacing::from(0),
+
                                    Some(Borders::All),
+
                                );
+
                            });
+
                        },
+
                    );
+
                },
+
            );
+
            ui.centered_text_view(frame, "Loading".slow_blink().yellow(), None);
+
        });
+
    }
+

+
    fn show_help_text(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.column_bar(
+
            frame,
+
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::Top),
+
        );
+

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

+
    fn show_help_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.column_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(),
+
            Spacing::from(0),
+
            Some(Borders::None),
+
        );
+
    }
+
}
+

+
impl App {
+
    fn apply_notifications(&mut self, notifications: Vec<Notification>) {
+
        let mut items = self.notifications.lock().unwrap();
+
        *items = notifications;
+
    }
+

+
    fn apply_sorting(&mut self) {
+
        let mut items = self.notifications.lock().unwrap();
+
        let context = self.context.lock().unwrap();
+
        // Apply sorting
+
        match context.sort_by.field {
+
            "timestamp" => items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
+
            "id" => items.sort_by(|a, b| a.id.cmp(&b.id)),
+
            _ => {}
+
        }
+
        if context.sort_by.reverse {
+
            items.reverse();
+
        }
+

+
        // Set project name
+
        let mode = match context.mode {
+
            RepositoryMode::ByRepo((rid, _)) => {
+
                let name = context.project.name().to_string();
+
                RepositoryMode::ByRepo((rid, Some(name)))
+
            }
+
            _ => context.mode.clone(),
+
        };
+

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

+
#[derive(Clone, Debug)]
+
pub struct Loader {
+
    context: Context,
+
}
+

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

+
#[derive(Debug)]
+
pub struct NotificationLoader {
+
    context: Context,
+
}
+

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

+
impl Task for NotificationLoader {
+
    type Return = Message;
+

+
    fn run(&self) -> anyhow::Result<Vec<Self::Return>> {
+
        let profile = self.context.profile.clone();
+
        let notifs = profile.notifications_mut()?;
+

+
        let notifications = match self.context.mode {
+
            RepositoryMode::All => {
+
                // Store all repos the notifs arised from, such that
+
                // they can be referenced when loading issues and patches
+
                let repos = notifs
+
                    .all()?
+
                    .filter_map(|notif| notif.ok())
+
                    .filter_map(|notif| {
+
                        profile
+
                            .storage
+
                            .repository(notif.repo)
+
                            .ok()
+
                            .map(|repo| (notif.repo, repo))
+
                    })
+
                    .collect::<HashMap<_, _>>();
+

+
                // Only retrieve issues and patches once per repository
+
                let (mut issues, mut patches) = (HashMap::new(), HashMap::new());
+
                notifs
+
                    .all()?
+
                    .filter_map(|notif| notif.ok())
+
                    .map(|notif| match repos.get(&notif.repo) {
+
                        Some(repo) => {
+
                            let project = repo.project()?;
+
                            let (issues, patches) = {
+
                                (
+
                                    match issues.entry(repo.id) {
+
                                        Entry::Occupied(e) => e.into_mut(),
+
                                        Entry::Vacant(e) => e.insert(profile.issues(repo)?),
+
                                    },
+
                                    match patches.entry(repo.id) {
+
                                        Entry::Occupied(e) => e.into_mut(),
+
                                        Entry::Vacant(e) => e.insert(profile.patches(repo)?),
+
                                    },
+
                                )
+
                            };
+

+
                            match NotificationKind::new(repo, issues, patches, &notif)? {
+
                                Some(kind) => Notification::new(&profile, &project, &notif, kind),
+
                                _ => Ok(None),
+
                            }
+
                        }
+
                        _ => Ok(None),
+
                    })
+
                    .filter_map(|notif| notif.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
            RepositoryMode::Contextual => {
+
                let repo = profile.storage.repository(self.context.rid)?;
+
                let project = repo.project()?;
+
                let issues = profile.issues(&repo)?;
+
                let patches = profile.patches(&repo)?;
+
                let by_repo = notifs.by_repo(&repo.id, "timestamp")?;
+

+
                by_repo
+
                    .filter_map(|notif| notif.ok())
+
                    .map(
+
                        |notif| match NotificationKind::new(&repo, &issues, &patches, &notif)? {
+
                            Some(kind) => Notification::new(&profile, &project, &notif, kind),
+
                            _ => Ok(None),
+
                        },
+
                    )
+
                    .filter_map(|notif| notif.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
            RepositoryMode::ByRepo((rid, _)) => {
+
                let repo = profile.storage.repository(rid)?;
+
                let project = repo.project()?;
+
                let issues = profile.issues(&repo)?;
+
                let patches = profile.patches(&repo)?;
+
                let by_repo = notifs.by_repo(&repo.id, "timestamp")?;
+

+
                by_repo
+
                    .filter_map(|notif| notif.ok())
+
                    .map(
+
                        |notif| match NotificationKind::new(&repo, &issues, &patches, &notif)? {
+
                            Some(kind) => Notification::new(&profile, &project, &notif, kind),
+
                            _ => Ok(None),
+
                        },
+
                    )
+
                    .filter_map(|notif| notif.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
        };
+

+
        Ok(vec![Message::Loaded(notifications)])
+
    }
+
}
+

+
impl Process<Message> for Loader {
+
    async fn process(&mut self, message: Message) -> anyhow::Result<Vec<Message>> {
+
        match message {
+
            Message::Initialize | Message::Reload => {
+
                let loader = NotificationLoader::new(self.context.clone());
+
                let messages = tokio::spawn(async move { loader.run() }).await.unwrap()?;
+
                Ok(messages)
+
            }
+
            _ => Ok(vec![]),
+
        }
+
    }
+
}
added bin/apps/issue.rs
@@ -0,0 +1,551 @@
+
#[path = "issue/list.rs"]
+
mod list;
+

+
use std::ffi::OsString;
+

+
use anyhow::anyhow;
+

+
use radicle::cob::thread::CommentId;
+
use radicle::identity::RepoId;
+
use radicle::issue::{IssueId, State};
+
use radicle::prelude::Did;
+
use radicle::{issue, storage, Profile};
+

+
use radicle_cli as cli;
+

+
use cli::terminal::patch::Message;
+
use cli::terminal::Context;
+
use cli::terminal::{Args, Error, Help};
+

+
use crate::apps::tui_issue::list::IssueOperation;
+
use crate::terminal;
+
use crate::ui::items::filter::DidFilter;
+
use crate::ui::items::issue::filter::IssueFilter;
+

+
pub const HELP: Help = Help {
+
    name: "issue",
+
    description: "Terminal interfaces for issues",
+
    version: env!("CARGO_PKG_VERSION"),
+
    usage: r#"
+
Usage
+

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

+
List options
+

+
    --json              Return JSON on stderr instead of calling `rad`
+

+
Other options
+

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

+
#[derive(Debug, PartialEq)]
+
pub struct Options {
+
    op: Operation,
+
    repo: Option<RepoId>,
+
}
+

+
#[derive(Debug, PartialEq)]
+
pub enum Operation {
+
    List { opts: ListOptions },
+
    Other { args: Vec<OsString> },
+
    Unknown { args: Vec<OsString> },
+
}
+

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

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct ListFilter {
+
    state: Option<State>,
+
    assigned: bool,
+
    assignees: Vec<Did>,
+
}
+

+
impl Default for ListFilter {
+
    fn default() -> Self {
+
        Self {
+
            state: Some(State::default()),
+
            assigned: false,
+
            assignees: vec![],
+
        }
+
    }
+
}
+

+
impl ListFilter {
+
    pub fn with_state(mut self, state: Option<State>) -> Self {
+
        self.state = state;
+
        self
+
    }
+

+
    pub fn with_assgined(mut self, assigned: bool) -> Self {
+
        self.assigned = assigned;
+
        self
+
    }
+

+
    pub fn with_assginee(mut self, assignee: Did) -> Self {
+
        self.assignees.push(assignee);
+
        self
+
    }
+
}
+

+
#[allow(clippy::from_over_into)]
+
impl Into<IssueFilter> for (Did, ListFilter) {
+
    fn into(self) -> IssueFilter {
+
        let (me, mut filter) = self;
+
        let mut and = filter
+
            .state
+
            .map(|s| vec![IssueFilter::State(s)])
+
            .unwrap_or(vec![]);
+

+
        let mut assignees = filter.assigned.then_some(vec![me]).unwrap_or_default();
+
        assignees.append(&mut filter.assignees);
+

+
        if assignees.len() == 1 {
+
            and.push(IssueFilter::Assignee(DidFilter::Single(
+
                *assignees.first().unwrap(),
+
            )));
+
        } else if assignees.len() > 1 {
+
            and.push(IssueFilter::Assignee(DidFilter::Or(assignees)));
+
        }
+

+
        IssueFilter::And(and)
+
    }
+
}
+

+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
+
pub struct ListOptions {
+
    filter: ListFilter,
+
    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.clone());
+
        let mut op = OperationName::List;
+
        let mut repo = None;
+
        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') => {
+
                    help = true;
+
                    // Only enable forwarding if it was not already disabled explicitly
+
                    forward = match forward {
+
                        Some(false) => Some(false),
+
                        _ => Some(true),
+
                    };
+
                }
+
                Long("all") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(None);
+
                }
+
                Long("open") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Open));
+
                }
+
                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 == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Closed {
+
                        reason: issue::CloseReason::Other,
+
                    }));
+
                }
+
                Long("assigned") if op == OperationName::List => {
+
                    if let Ok(val) = parser.value() {
+
                        list_opts.filter = list_opts
+
                            .filter
+
                            .with_assginee(cli::terminal::args::did(&val)?);
+
                    } else {
+
                        list_opts.filter = list_opts.filter.with_assgined(true);
+
                    }
+
                }
+

+
                Long("repo") => {
+
                    let val = parser.value()?;
+
                    let rid = cli::terminal::args::rid(&val)?;
+

+
                    repo = Some(rid);
+
                }
+

+
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
+
                    "list" => op = OperationName::List,
+
                    _ => {
+
                        op = OperationName::Unknown;
+
                        // Only enable forwarding if it was not already disabled explicitly
+
                        forward = match forward {
+
                            Some(false) => Some(false),
+
                            _ => Some(true),
+
                        };
+
                    }
+
                },
+
                _ => {
+
                    if op == OperationName::List {
+
                        return Err(anyhow!(arg.unexpected()));
+
                    }
+
                }
+
            }
+
        }
+

+
        // 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 },
+
            },
+
            OperationName::Unknown if !forward => Operation::Unknown { args },
+
            _ => Operation::Other { args },
+
        };
+

+
        Ok((Options { op, repo }, vec![]))
+
    }
+
}
+

+
#[tokio::main]
+
pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
+
    use radicle::storage::ReadStorage;
+

+
    let (_, rid) = radicle::rad::cwd()
+
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+

+
    match options.op {
+
        Operation::List { opts } => {
+
            if let Err(err) = crate::log::enable() {
+
                println!("{err}");
+
            }
+
            log::info!("Starting issue listing app in project {rid}..");
+

+
            #[derive(Default)]
+
            struct PreviousState {
+
                issue_id: Option<IssueId>,
+
                comment_id: Option<CommentId>,
+
                search: Option<String>,
+
            }
+

+
            // Store issue and comment selection across app runs in order to
+
            // preselect them when re-running the app.
+
            let mut state = PreviousState::default();
+

+
            loop {
+
                let profile = ctx.profile()?;
+
                let me = profile.did();
+
                let rid = options.repo.unwrap_or(rid);
+
                let repository = profile.storage.repository(rid)?;
+

+
                let context = list::Context {
+
                    profile,
+
                    repository,
+
                    filter: (me, opts.filter.clone()).into(),
+
                    search: state.search.clone(),
+
                    issue: state.issue_id,
+
                    comment: state.comment_id,
+
                };
+

+
                let tui = list::Tui::new(context);
+
                let selection = tui.run().await?;
+

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

+
                    log::info!("Exiting issue listing app..");
+
                    eprint!("{selection}");
+
                } else if let Some(selection) = selection {
+
                    if let Some(operation) = selection.operation.clone() {
+
                        match operation {
+
                            IssueOperation::Show { args } => {
+
                                state = PreviousState {
+
                                    issue_id: Some(args.id()),
+
                                    comment_id: None,
+
                                    search: Some(args.search()),
+
                                };
+
                                terminal::run_rad(
+
                                    Some("issue"),
+
                                    &["show".into(), args.id().to_string().into()],
+
                                )?;
+
                            }
+
                            IssueOperation::Edit { args, comment_id } => {
+
                                state = PreviousState {
+
                                    issue_id: Some(args.id()),
+
                                    comment_id,
+
                                    search: Some(args.search()),
+
                                };
+
                                match comment_id {
+
                                    Some(comment_id) => {
+
                                        terminal::run_rad(
+
                                            Some("issue"),
+
                                            &[
+
                                                "comment".into(),
+
                                                args.id().to_string().into(),
+
                                                "--edit".into(),
+
                                                comment_id.to_string().into(),
+
                                            ],
+
                                        )?;
+
                                    }
+
                                    _ => {
+
                                        terminal::run_rad(
+
                                            Some("issue"),
+
                                            &["edit".into(), args.id().to_string().into()],
+
                                        )?;
+
                                    }
+
                                }
+
                            }
+
                            IssueOperation::Solve { args } => {
+
                                state = PreviousState {
+
                                    issue_id: Some(args.id()),
+
                                    comment_id: None,
+
                                    search: Some(args.search()),
+
                                };
+
                                terminal::run_rad(
+
                                    Some("issue"),
+
                                    &[
+
                                        "state".into(),
+
                                        args.id().to_string().into(),
+
                                        "--solved".into(),
+
                                    ],
+
                                )?;
+
                            }
+
                            IssueOperation::Close { args } => {
+
                                state = PreviousState {
+
                                    issue_id: Some(args.id()),
+
                                    comment_id: None,
+
                                    search: Some(args.search()),
+
                                };
+
                                terminal::run_rad(
+
                                    Some("issue"),
+
                                    &[
+
                                        "state".into(),
+
                                        args.id().to_string().into(),
+
                                        "--closed".into(),
+
                                    ],
+
                                )?;
+
                            }
+
                            IssueOperation::Reopen { args } => {
+
                                state = PreviousState {
+
                                    issue_id: Some(args.id()),
+
                                    comment_id: None,
+
                                    search: Some(args.search()),
+
                                };
+
                                terminal::run_rad(
+
                                    Some("issue"),
+
                                    &[
+
                                        "state".into(),
+
                                        args.id().to_string().into(),
+
                                        "--open".into(),
+
                                    ],
+
                                )?;
+
                            }
+
                            IssueOperation::Comment { args, reply_to } => {
+
                                let comment_id = comment(
+
                                    &tui.context().profile,
+
                                    &tui.context().repository,
+
                                    args.id(),
+
                                    Message::Edit,
+
                                    reply_to,
+
                                )?;
+
                                state = PreviousState {
+
                                    issue_id: Some(args.id()),
+
                                    comment_id: Some(comment_id),
+
                                    search: Some(args.search()),
+
                                };
+
                            }
+
                        }
+
                    }
+
                } else {
+
                    break;
+
                }
+
            }
+
        }
+
        Operation::Other { args } => {
+
            terminal::run_rad(Some("issue"), &args)?;
+
        }
+
        Operation::Unknown { .. } => {
+
            anyhow::bail!("unknown operation provided");
+
        }
+
    }
+

+
    Ok(())
+
}
+

+
fn comment(
+
    profile: &Profile,
+
    repo: &storage::git::Repository,
+
    issue_id: IssueId,
+
    message: Message,
+
    reply_to: Option<CommentId>,
+
) -> Result<CommentId, anyhow::Error> {
+
    let mut issues = profile.issues_mut(repo)?;
+
    let signer = cli::terminal::signer(profile)?;
+
    let mut issue = issues.get_mut(&issue_id)?;
+
    let (root_comment_id, _) = issue.root();
+
    let body = terminal::prompt_comment(message, issue.thread(), reply_to, None)?;
+
    let comment_id = issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
+

+
    Ok(comment_id)
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use radicle_cli::terminal::args::Error;
+
    use radicle_cli::terminal::Args;
+

+
    use super::{ListOptions, Operation, Options};
+

+
    #[test]
+
    fn empty_operation_should_default_to_list_and_not_be_forwarded(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec![];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["--help".into(), "--no-forward".into()];
+

+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+
        assert!(matches!(actual, Error::Help));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec!["--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec!["list".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_should_not_be_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec!["list".into(), "--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["list".into(), "--help".into(), "--no-forward".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+

+
        assert!(matches!(actual, Error::Help));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_should_not_be_forwarded_reversed(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--no-forward".into(), "--help".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+

+
        assert!(matches!(actual, Error::Help));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into()];
+
        let expected_op = Operation::Other { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into(), "--no-forward".into()];
+
        let expected_op = Operation::Unknown { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+
}
added bin/apps/issue/list.rs
@@ -0,0 +1,1229 @@
+
use std::collections::{HashMap, HashSet};
+
use std::str::FromStr;
+
use std::sync::{Arc, Mutex};
+

+
use radicle::cob::ObjectId;
+
use serde::Serialize;
+

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

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

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

+
use radicle_tui as tui;
+

+
use tui::event::Key;
+
use tui::store;
+
use tui::task::EmptyProcessors;
+
use tui::ui;
+
use tui::ui::layout::Spacing;
+
use tui::ui::span;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::{
+
    Borders, Column, ContainerState, TableState, TextEditState, TextViewState, TreeState, Window,
+
};
+
use tui::ui::{BufferedValue, Show, ToRow, Ui};
+
use tui::{Channel, Exit};
+

+
use crate::cob::issue;
+
use crate::settings;
+
use crate::ui::format;
+
use crate::ui::items::filter::Filter;
+
use crate::ui::items::issue::filter::IssueFilter;
+
use crate::ui::items::issue::Issue;
+
use crate::ui::items::HasId;
+

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

+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub struct OperationArguments {
+
    id: IssueId,
+
    search: String,
+
}
+

+
impl OperationArguments {
+
    pub fn id(&self) -> ObjectId {
+
        self.id
+
    }
+

+
    pub fn search(&self) -> String {
+
        self.search.clone()
+
    }
+
}
+

+
impl TryFrom<(&Vec<Issue>, &AppState)> for OperationArguments {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Vec<Issue>, &AppState)) -> Result<Self> {
+
        let (issues, state) = value;
+
        let selected = state.browser.selected();
+
        let id = selected
+
            .and_then(|s| issues.get(s))
+
            .ok_or(anyhow!("No issue selected"))?
+
            .id;
+
        let search = state.browser.search.read().text;
+

+
        Ok(Self { id, search })
+
    }
+
}
+

+
/// The selected issue operation returned by the operation
+
/// selection widget.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub enum IssueOperation {
+
    Edit {
+
        args: OperationArguments,
+
        comment_id: Option<CommentId>,
+
    },
+
    Show {
+
        args: OperationArguments,
+
    },
+
    Close {
+
        args: OperationArguments,
+
    },
+
    Solve {
+
        args: OperationArguments,
+
    },
+
    Reopen {
+
        args: OperationArguments,
+
    },
+
    Comment {
+
        args: OperationArguments,
+
        reply_to: Option<CommentId>,
+
    },
+
}
+

+
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
+
`Tab`:      focus next section
+
`BackTab`:  focus previous section
+
`Esc`:      Cancel
+
`q`:        Quit
+

+
# Specific keybindings
+

+
`/`:        Search
+
`Enter`:    Show issue
+
`e`:        Edit issue
+
`s`:        Solve issue
+
`l`:        Close issue
+
`o`:        Re-open issue
+
`c`:        Reply to comment
+
`p`:        Toggle issue preview
+
`?`:        Show help"#;
+

+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub filter: IssueFilter,
+
    pub search: Option<String>,
+
    pub issue: Option<IssueId>,
+
    pub comment: Option<CommentId>,
+
}
+

+
pub(crate) struct Tui {
+
    pub(crate) context: Context,
+
}
+

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

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let viewport = Viewport::Inline(20);
+
        let channel = Channel::default();
+
        let state = App::try_from(&self.context)?;
+

+
        tui::im(state, viewport, channel, EmptyProcessors::new()).await
+
    }
+

+
    pub fn context(&self) -> &Context {
+
        &self.context
+
    }
+
}
+

+
mod args {
+
    use super::*;
+
    use crate::ui::items::CommentItem;
+

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

+
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
+
    pub(crate) enum Section {
+
        #[default]
+
        Browser,
+
        Issue,
+
        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::Issue),
+
                2 => Ok(Section::Comment),
+
                _ => bail!("Unknown section index: {}", value),
+
            }
+
        }
+
    }
+

+
    #[derive(Clone, Debug)]
+
    pub(crate) struct Browser {
+
        pub(crate) issues: TableState,
+
        pub(crate) search: BufferedValue<TextEditState>,
+
        pub(crate) show_search: bool,
+
    }
+

+
    impl Browser {
+
        pub fn selected(&self) -> Option<usize> {
+
            self.issues.selected()
+
        }
+
    }
+

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

+
    impl Preview {
+
        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().find(|item| item.id == comment_id))
+
            })
+
        }
+

+
        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
+
        }
+
    }
+

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

+
#[derive(Clone, Debug)]
+
pub enum Change {
+
    Page { page: args::Page },
+
    Section { state: ContainerState },
+
    Issue { state: TableState },
+
    Comment { state: TreeState<String> },
+
    CommentBody { state: TextViewState },
+
    ShowSearch { state: bool, apply: bool },
+
    ShowPreview { state: bool },
+
    Search { state: BufferedValue<TextEditState> },
+
    Help { state: TextViewState },
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Changed(Change),
+
    Exit { operation: Option<IssueOperation> },
+
    Quit,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct AppState {
+
    page: args::Page,
+
    sections: ContainerState,
+
    browser: args::Browser,
+
    preview: args::Preview,
+
    help: TextViewState,
+
    filter: IssueFilter,
+
    theme: Theme,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct App {
+
    issues: Arc<Mutex<Vec<Issue>>>,
+
    state: AppState,
+
}
+

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

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

+
        let issues = issue::all(&context.profile, &context.repository)?;
+
        let search = context.search.as_ref().map(|s| s.trim().to_string());
+
        let (search, filter) = match search {
+
            Some(search) => (
+
                search.clone(),
+
                IssueFilter::from_str(search.trim()).unwrap_or(IssueFilter::Invalid),
+
            ),
+
            None => {
+
                let filter = context.filter.clone();
+
                (filter.to_string().trim().to_string(), filter)
+
            }
+
        };
+

+
        // Convert into UI items
+
        let mut issues: Vec<_> = issues
+
            .into_iter()
+
            .flat_map(|issue| Issue::new(&context.profile, issue).ok())
+
            .collect();
+

+
        issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+

+
        // Pre-select comments per issue. If a comment to pre-select is given,
+
        // find identifier path needed for selection. Select root comment
+
        // otherwise.
+
        let selected_comments: HashMap<_, _> = issues
+
            .iter()
+
            .map(|issue| {
+
                let comment_ids = match context.comment {
+
                    Some(comment_id) if issue.has_comment(&comment_id) => {
+
                        issue.path_to_comment(&comment_id).unwrap_or_default()
+
                    }
+
                    _ => issue
+
                        .root_comments()
+
                        .first()
+
                        .map(|c| vec![c.id])
+
                        .unwrap_or_default(),
+
                };
+
                (issue.id, comment_ids)
+
            })
+
            .collect();
+

+
        let browser = args::Browser {
+
            issues: TableState::new(Some(
+
                context
+
                    .issue
+
                    .and_then(|id| {
+
                        issues
+
                            .iter()
+
                            .filter(|item| filter.matches(item))
+
                            .position(|item| item.id() == id)
+
                    })
+
                    .unwrap_or(0),
+
            )),
+
            search: BufferedValue::new(TextEditState {
+
                text: search.clone(),
+
                cursor: search.chars().count(),
+
            }),
+
            show_search: false,
+
        };
+

+
        let preview = args::Preview {
+
            show: true,
+
            issue: browser
+
                .selected()
+
                .and_then(|s| {
+
                    issues
+
                        .iter()
+
                        .filter(|item| filter.matches(item))
+
                        .collect::<Vec<_>>()
+
                        .get(s)
+
                        .cloned()
+
                })
+
                .cloned(),
+
            selected_comments,
+
            comment: TextViewState::new(Position::default()),
+
        };
+

+
        let section = if context.comment.is_some() {
+
            args::Section::Issue
+
        } else {
+
            args::Section::Browser
+
        };
+

+
        Ok(Self {
+
            issues: Arc::new(Mutex::new(issues)),
+
            state: AppState {
+
                page: args::Page::Main,
+
                sections: ContainerState::new(3, Some(section as usize)),
+
                browser,
+
                preview,
+
                filter,
+
                help: TextViewState::new(Position::default()),
+
                theme,
+
            },
+
        })
+
    }
+
}
+

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

+
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => Some(Exit {
+
                value: Some(Selection {
+
                    operation,
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::Changed(changed) => match changed {
+
                Change::Page { page } => {
+
                    self.state.page = page;
+
                    None
+
                }
+
                Change::Section { state } => {
+
                    self.state.sections = state;
+
                    None
+
                }
+
                Change::Issue { state } => {
+
                    let issues = self.issues.lock().unwrap();
+
                    let issues = issues
+
                        .clone()
+
                        .into_iter()
+
                        .filter(|issue| self.state.filter.matches(issue))
+
                        .collect::<Vec<_>>();
+

+
                    self.state.browser.issues = state;
+
                    self.state.preview.issue = self
+
                        .state
+
                        .browser
+
                        .selected()
+
                        .and_then(|s| issues.get(s).cloned());
+
                    self.state.preview.comment = TextViewState::new(Position::default());
+
                    None
+
                }
+
                Change::ShowSearch { state, apply } => {
+
                    if state {
+
                        self.state.sections = ContainerState::new(self.state.sections.len(), None);
+
                        self.state.browser.show_search = true;
+
                    } else {
+
                        let issues = self.issues.lock().unwrap();
+
                        let issues = issues
+
                            .clone()
+
                            .into_iter()
+
                            .filter(|issue| self.state.filter.matches(issue))
+
                            .collect::<Vec<_>>();
+

+
                        self.state.preview.issue = self
+
                            .state
+
                            .browser
+
                            .selected()
+
                            .and_then(|s| issues.get(s).cloned());
+
                        self.state.sections =
+
                            ContainerState::new(self.state.sections.len(), Some(0));
+
                        self.state.browser.show_search = false;
+

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

+
                        self.state.filter =
+
                            IssueFilter::from_str(&self.state.browser.search.read().text)
+
                                .unwrap_or_default();
+
                    }
+
                    None
+
                }
+
                Change::ShowPreview { state } => {
+
                    self.state.preview.show = state;
+
                    self.state.sections = ContainerState::new(if state { 3 } else { 1 }, Some(0));
+
                    None
+
                }
+
                Change::Search { state } => {
+
                    let issues = self.issues.lock().unwrap();
+
                    let issues = issues
+
                        .clone()
+
                        .into_iter()
+
                        .filter(|issue| self.state.filter.matches(issue))
+
                        .collect::<Vec<_>>();
+

+
                    self.state.browser.search = state.clone();
+
                    self.state.filter =
+
                        IssueFilter::from_str(&state.read().text).unwrap_or_default();
+
                    self.state.browser.issues.select_first();
+

+
                    self.state.preview.issue = self
+
                        .state
+
                        .browser
+
                        .selected()
+
                        .and_then(|s| issues.get(s).cloned());
+
                    None
+
                }
+
                Change::Comment { state } => {
+
                    if let Some(item) = &self.state.preview.issue {
+
                        self.state.preview.selected_comments.insert(
+
                            item.id,
+
                            state
+
                                .internal
+
                                .selected()
+
                                .iter()
+
                                .map(|s| CommentId::from_str(s).unwrap())
+
                                .collect(),
+
                        );
+
                    }
+
                    self.state.preview.comment = TextViewState::new(Position::default());
+
                    None
+
                }
+
                Change::CommentBody { state } => {
+
                    self.state.preview.comment = state;
+
                    None
+
                }
+
                Change::Help { state } => {
+
                    self.state.help = state;
+
                    None
+
                }
+
            },
+
        }
+
    }
+
}
+

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

+
                    ui.layout(
+
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
+
                        page_focus,
+
                        |ui| {
+
                            let (mut focus, count) =
+
                                { (self.state.sections.focus(), self.state.sections.len()) };
+

+
                            let group = ui.container(
+
                                ui::Layout::Expandable3 {
+
                                    left_only: !self.state.preview.show,
+
                                },
+
                                &mut focus,
+
                                |ui| {
+
                                    self.show_browser(frame, ui);
+
                                    self.show_issue(frame, ui);
+
                                    self.show_comment(frame, ui);
+
                                },
+
                            );
+

+
                            if group.response.changed {
+
                                ui.send_message(Message::Changed(Change::Section {
+
                                    state: ContainerState::new(count, focus),
+
                                }));
+
                            }
+

+
                            ui.layout(
+
                                Layout::vertical(match show_search {
+
                                    true => [2, 0],
+
                                    false => [1, 1],
+
                                }),
+
                                Some(0),
+
                                |ui| {
+
                                    use args::Section;
+
                                    if let Some(section) = focus {
+
                                        match Section::try_from(section).unwrap_or_default() {
+
                                            Section::Browser => {
+
                                                self.show_browser_context(frame, ui);
+
                                                self.show_browser_shortcuts(frame, ui);
+
                                            }
+
                                            Section::Issue => {
+
                                                self.show_issue_context(frame, ui);
+
                                                self.show_issue_shortcuts(frame, ui);
+
                                            }
+
                                            Section::Comment => {
+
                                                self.show_comment_context(frame, ui);
+
                                                self.show_comment_shortcuts(frame, ui);
+
                                            }
+
                                        }
+
                                    } else if show_search {
+
                                        self.show_browser_search(frame, ui);
+
                                    }
+
                                },
+
                            );
+
                        },
+
                    );
+

+
                    if !show_search {
+
                        if ui.has_input(|key| key == Key::Char('p')) {
+
                            ui.send_message(Message::Changed(Change::ShowPreview {
+
                                state: !self.state.preview.show,
+
                            }));
+
                        }
+
                        if ui.has_input(|key| key == Key::Char('?')) {
+
                            ui.send_message(Message::Changed(Change::Page {
+
                                page: args::Page::Help,
+
                            }));
+
                        }
+
                    }
+
                }
+
                args::Page::Help => {
+
                    let layout = Layout::vertical([
+
                        Constraint::Length(3),
+
                        Constraint::Fill(1),
+
                        Constraint::Length(1),
+
                        Constraint::Length(1),
+
                    ]);
+

+
                    ui.container(layout, &mut Some(1), |ui| {
+
                        self.show_help_text(frame, ui);
+
                        self.show_help_context(frame, ui);
+

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

+
                    if ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page {
+
                            page: args::Page::Main,
+
                        }));
+
                    }
+
                }
+
            }
+

+
            if ui.has_input(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
            if ui.has_input(|key| key == Key::Ctrl('c')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
+
}
+

+
impl App {
+
    pub fn show_browser(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        let issues = self.issues.lock().unwrap();
+
        let issues = issues
+
            .iter()
+
            .filter(|patch| self.state.filter.matches(patch))
+
            .cloned()
+
            .collect::<Vec<_>>();
+
        let browser = &self.state.browser;
+
        let preview = &self.state.preview;
+
        let mut selected = browser.issues.selected();
+

+
        let 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(),
+
        ];
+

+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.column_bar(frame, header.to_vec(), Spacing::from(1), Some(Borders::Top));
+

+
                let table = ui.table(
+
                    frame,
+
                    &mut selected,
+
                    &issues,
+
                    header.to_vec(),
+
                    None,
+
                    Spacing::from(1),
+
                    Some(Borders::BottomSides),
+
                );
+
                if table.changed {
+
                    ui.send_message(Message::Changed(Change::Issue {
+
                        state: TableState::new(selected),
+
                    }));
+
                }
+
            },
+
        );
+

+
        if ui.has_input(|key| key == Key::Char('/')) {
+
            ui.send_message(Message::Changed(Change::ShowSearch {
+
                state: true,
+
                apply: false,
+
            }));
+
        }
+

+
        if let Ok(args) = OperationArguments::try_from((&issues, &self.state)) {
+
            if ui.has_input(|key| key == Key::Enter) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Show { args: args.clone() }),
+
                });
+
            }
+

+
            if ui.has_input(|key| key == Key::Char('e')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Edit {
+
                        args: args.clone(),
+
                        comment_id: preview.selected_comment().map(|c| c.id),
+
                    }),
+
                });
+
            }
+

+
            if ui.has_input(|key| key == Key::Char('s')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Solve { args: args.clone() }),
+
                });
+
            }
+

+
            if ui.has_input(|key| key == Key::Char('l')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Close { args: args.clone() }),
+
                });
+
            }
+

+
            if ui.has_input(|key| key == Key::Char('o')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(IssueOperation::Reopen { args }),
+
                });
+
            }
+
        }
+
    }
+

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

+
        let text_edit = ui.text_edit_singleline(
+
            frame,
+
            &mut search_text,
+
            &mut search_cursor,
+
            Some("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::Changed(Change::Search { state: search }));
+
        }
+

+
        if ui.has_input(|key| key == Key::Esc) {
+
            ui.send_message(Message::Changed(Change::ShowSearch {
+
                state: false,
+
                apply: false,
+
            }));
+
        }
+
        if ui.has_input(|key| key == Key::Enter) {
+
            ui.send_message(Message::Changed(Change::ShowSearch {
+
                state: false,
+
                apply: true,
+
            }));
+
        }
+
    }
+

+
    fn show_browser_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        use radicle::issue::{CloseReason, State};
+

+
        let context = {
+
            let issues = self.issues.lock().unwrap();
+
            let filter = &self.state.filter;
+
            let filtered = issues
+
                .iter()
+
                .filter(|issue| filter.matches(issue))
+
                .collect::<Vec<_>>();
+

+
            let browser = &self.state.browser;
+
            let search = browser.search.read().text;
+

+
            let mut open = 0;
+
            let mut other = 0;
+
            let mut solved = 0;
+
            for issue in &filtered {
+
                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 filtered_counts = format!(" {}/{} ", filtered.len(), issues.len());
+
            if !self.state.filter.has_state() {
+
                [
+
                    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)
+
                            .cyan()
+
                            .dim(),
+
                        Constraint::Fill(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw(" ● ")
+
                            .into_right_aligned_line()
+
                            .style(ui.theme().bar_on_black_style)
+
                            .green()
+
                            .dim(),
+
                        Constraint::Length(3),
+
                    ),
+
                    Column::new(
+
                        Span::from(open.to_string())
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line(),
+
                        Constraint::Length(open.to_string().chars().count() as u16),
+
                    ),
+
                    Column::new(
+
                        Span::raw(" ● ")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line()
+
                            .red()
+
                            .dim(),
+
                        Constraint::Length(3),
+
                    ),
+
                    Column::new(
+
                        Span::from(closed.to_string())
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line(),
+
                        Constraint::Length(closed.to_string().chars().count() as u16),
+
                    ),
+
                    Column::new(
+
                        Span::from(" ")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line(),
+
                        Constraint::Length(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()
+
            } else {
+
                [
+
                    Column::new(
+
                        Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                        Constraint::Length(8),
+
                    ),
+
                    Column::new(
+
                        Span::from(" ")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line(),
+
                        Constraint::Length(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw(search.to_string())
+
                            .into_left_aligned_line()
+
                            .style(ui.theme().bar_on_black_style)
+
                            .cyan()
+
                            .dim(),
+
                        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()
+
            }
+
        };
+

+
        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
+
    }
+

+
    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        use radicle::issue::State;
+

+
        let issues = self.issues.lock().unwrap();
+
        let issues = issues
+
            .iter()
+
            .filter(|issue| self.state.filter.matches(issue))
+
            .collect::<Vec<_>>();
+

+
        let mut shortcuts = vec![("/", "search"), ("enter", "show"), ("e", "edit")];
+
        if let Some(issue) = self.state.browser.selected().and_then(|i| issues.get(i)) {
+
            let actions = match issue.state {
+
                State::Open => vec![("s", "solve"), ("l", "close")],
+
                State::Closed { .. } => vec![("o", "re-open")],
+
            };
+
            shortcuts.extend_from_slice(&actions);
+
        }
+

+
        let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];
+

+
        ui.layout(
+
            Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]),
+
            None,
+
            |ui| {
+
                ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left);
+
                ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right);
+
            },
+
        );
+
    }
+

+
    pub fn show_issue(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        #[derive(Clone)]
+
        struct Property<'a>(Span<'a>, Text<'a>);
+

+
        impl<'a> ToRow<3> for Property<'a> {
+
            fn to_row(&self) -> [ratatui::widgets::Cell<'_>; 3] {
+
                ["".into(), self.0.clone().into(), self.1.clone().into()]
+
            }
+
        }
+

+
        let issues = self.issues.lock().unwrap();
+
        let issues = issues
+
            .iter()
+
            .filter(|issue| self.state.filter.matches(issue))
+
            .cloned()
+
            .collect::<Vec<_>>();
+
        let issue = self.state.browser.selected().and_then(|i| issues.get(i));
+
        let properties = issue
+
            .map(|issue| {
+
                use radicle::issue;
+

+
                let author: Text<'_> = match &issue.author.alias {
+
                    Some(alias) => {
+
                        if issue.author.you {
+
                            Line::from(
+
                                [
+
                                    span::alias(alias.as_ref()),
+
                                    Span::raw(" "),
+
                                    span::alias("(you)").dim().italic(),
+
                                ]
+
                                .to_vec(),
+
                            )
+
                            .into()
+
                        } else {
+
                            Line::from(
+
                                [
+
                                    span::alias(alias.as_ref()),
+
                                    Span::raw(" "),
+
                                    span::alias(&format!(
+
                                        "({})",
+
                                        issue.author.human_nid.clone().unwrap_or_default()
+
                                    ))
+
                                    .dim()
+
                                    .italic(),
+
                                ]
+
                                .to_vec(),
+
                            )
+
                            .into()
+
                        }
+
                    }
+
                    None => match &issue.author.human_nid {
+
                        Some(nid) => span::alias(nid).dim().into(),
+
                        None => span::blank().into(),
+
                    },
+
                };
+

+
                let status = match issue.state {
+
                    issue::State::Open => Text::from("open").green(),
+
                    issue::State::Closed { reason } => match reason {
+
                        issue::CloseReason::Solved => Line::from(
+
                            [
+
                                Span::from("closed").red(),
+
                                Span::raw(" "),
+
                                Span::from("(solved)").red().italic().dim(),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .into(),
+
                        issue::CloseReason::Other => Text::from("closed").red(),
+
                    },
+
                };
+

+
                vec![
+
                    Property(Span::from("Title"), Text::from(issue.title.clone()).bold()),
+
                    Property(Span::from("Issue"), Text::from(issue.id.to_string()).cyan()),
+
                    Property(Span::from("Author"), author.magenta()),
+
                    Property(
+
                        Span::from("Labels"),
+
                        Text::from(format::labels(&issue.labels)).blue(),
+
                    ),
+
                    Property(Span::from("Status"), status),
+
                ]
+
            })
+
            .unwrap_or_default();
+

+
        let preview = &self.state.preview;
+
        let comment = preview.selected_comment();
+
        let root = preview.root_comments();
+
        let mut opened = Some(preview.opened_comments());
+
        let mut selected = Some(preview.selected_comment_ids());
+

+
        ui.layout(
+
            Layout::vertical([Constraint::Length(7), Constraint::Fill(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.table(
+
                    frame,
+
                    &mut None,
+
                    &properties,
+
                    vec![
+
                        Column::new("", Constraint::Length(1)),
+
                        Column::new("", Constraint::Length(12)),
+
                        Column::new("", Constraint::Fill(1)),
+
                    ],
+
                    None,
+
                    Spacing::from(0),
+
                    Some(Borders::Top),
+
                );
+
                let comments = ui.tree(
+
                    frame,
+
                    &root,
+
                    &mut opened,
+
                    &mut selected,
+
                    Some(Borders::BottomSides),
+
                );
+
                if comments.changed {
+
                    let mut state = tui_tree_widget::TreeState::default();
+
                    if let Some(opened) = opened {
+
                        for open in opened {
+
                            state.open(open);
+
                        }
+
                    }
+
                    if let Some(selected) = selected {
+
                        state.select(selected);
+
                    }
+

+
                    ui.send_message(Message::Changed(Change::Comment {
+
                        state: TreeState { internal: state },
+
                    }));
+
                }
+

+
                if let Ok(args) = OperationArguments::try_from((&issues, &self.state)) {
+
                    if ui.has_input(|key| key == Key::Char('c')) {
+
                        ui.send_message(Message::Exit {
+
                            operation: Some(IssueOperation::Comment {
+
                                args: args.clone(),
+
                                reply_to: comment.map(|c| c.id),
+
                            }),
+
                        });
+
                    }
+

+
                    if ui.has_input(|key| key == Key::Char('e')) {
+
                        ui.send_message(Message::Exit {
+
                            operation: Some(IssueOperation::Edit {
+
                                args,
+
                                comment_id: comment.map(|c| c.id),
+
                            }),
+
                        });
+
                    }
+
                }
+
            },
+
        );
+
    }
+

+
    pub fn show_issue_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.column_bar(
+
            frame,
+
            [
+
                Column::new(
+
                    Span::raw(" Comment ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(9),
+
                ),
+
                Column::new(
+
                    Span::raw(" ".to_string())
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
            ]
+
            .to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::None),
+
        );
+
    }
+

+
    pub fn show_issue_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        let shortcuts = vec![("e", "edit"), ("c", "reply")];
+
        let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];
+

+
        ui.layout(
+
            Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]),
+
            None,
+
            |ui| {
+
                ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left);
+
                ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right);
+
            },
+
        );
+
    }
+

+
    pub fn show_comment(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        let (text, reactions, mut cursor) = {
+
            let comment = self.state.preview.selected_comment();
+
            let body: String = comment
+
                .map(|comment| comment.body.clone())
+
                .unwrap_or_default();
+
            let reactions = comment.and_then(|comment| {
+
                let reactions = comment.accumulated_reactions();
+
                if !reactions.is_empty() {
+
                    let reactions = reactions.iter().fold(String::new(), |all, (r, acc)| {
+
                        if *acc > 1_usize {
+
                            [all, format!("{r}{acc} ")].concat()
+
                        } else {
+
                            [all, format!("{r} ")].concat()
+
                        }
+
                    });
+
                    Some(reactions)
+
                } else {
+
                    None
+
                }
+
            });
+

+
            (body, reactions, self.state.preview.comment.clone().cursor())
+
        };
+
        let comment = match reactions {
+
            Some(reactions) => {
+
                ui.text_view_with_footer(frame, text, reactions, &mut cursor, Some(Borders::All))
+
            }
+
            None => ui.text_view(frame, text, &mut cursor, Some(Borders::All)),
+
        };
+

+
        if comment.changed {
+
            ui.send_message(Message::Changed(Change::CommentBody {
+
                state: TextViewState::new(cursor),
+
            }))
+
        }
+
    }
+

+
    pub fn show_comment_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.column_bar(
+
            frame,
+
            [
+
                Column::new(
+
                    Span::raw(" Comment ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(9),
+
                ),
+
                Column::new(
+
                    Span::raw(" ".to_string())
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
            ]
+
            .to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::None),
+
        );
+
    }
+

+
    pub fn show_comment_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        let shortcuts = vec![("e", "edit"), ("c", "reply")];
+
        let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];
+

+
        ui.layout(
+
            Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]),
+
            None,
+
            |ui| {
+
                ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left);
+
                ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right);
+
            },
+
        );
+
    }
+

+
    fn show_help_text(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.column_bar(
+
            frame,
+
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::Top),
+
        );
+

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

+
    fn show_help_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.column_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(),
+
            Spacing::from(0),
+
            Some(Borders::None),
+
        );
+
    }
+
}
added bin/apps/patch.rs
@@ -0,0 +1,716 @@
+
#[path = "patch/list.rs"]
+
mod list;
+
#[path = "patch/review.rs"]
+
mod review;
+

+
use std::ffi::OsString;
+

+
use anyhow::anyhow;
+

+
use radicle::cob::ObjectId;
+
use radicle::identity::RepoId;
+
use radicle::patch::{Patch, Revision, RevisionId, Status};
+
use radicle::prelude::Did;
+
use radicle::storage::git::Repository;
+

+
use radicle_cli::git::Rev;
+
use radicle_cli::terminal::args;
+
use radicle_cli::terminal::args::{string, Args, Error, Help};
+

+
use crate::terminal;
+
use crate::ui::items::filter::DidFilter;
+
use crate::ui::items::patch::filter::PatchFilter;
+

+
pub const HELP: Help = Help {
+
    name: "patch",
+
    description: "Terminal interfaces for patches",
+
    version: env!("CARGO_PKG_VERSION"),
+
    usage: r#"
+
Usage
+

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

+
List options
+

+
    --all                   Show all patches, including merged and archived patches
+
    --archived              Show only archived patches
+
    --merged                Show only merged patches
+
    --open                  Show only open patches (default)
+
    --draft                 Show only draft patches
+
    --authored              Show only patches that you have authored
+
    --author <did>          Show only patched where the given user is an author
+
                            (may be specified multiple times)
+

+
    --json                  Return JSON on stderr instead of calling `rad`
+

+
Other options
+

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

+
#[derive(Debug, PartialEq)]
+
pub struct Options {
+
    op: Operation,
+
    repo: Option<RepoId>,
+
}
+

+
#[derive(Debug, PartialEq)]
+
pub enum Operation {
+
    List { opts: ListOptions },
+
    Review { opts: ReviewOptions },
+
    Unknown { args: Vec<OsString> },
+
    Other { args: Vec<OsString> },
+
}
+

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

+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
+
pub struct ListOptions {
+
    filter: ListFilter,
+
    json: bool,
+
}
+

+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
+
pub struct ReviewOptions {
+
    edit: bool,
+
    patch_id: Option<Rev>,
+
    revision_id: Option<Rev>,
+
}
+

+
impl ReviewOptions {
+
    pub fn revision_or_latest<'a>(
+
        &'a self,
+
        patch: &'a Patch,
+
        repo: &Repository,
+
    ) -> anyhow::Result<(RevisionId, &'a Revision)> {
+
        let revision_id = self
+
            .revision_id
+
            .as_ref()
+
            .map(|rev| rev.resolve::<radicle::git::Oid>(&repo.backend))
+
            .transpose()?
+
            .map(radicle::cob::patch::RevisionId::from);
+

+
        match revision_id {
+
            Some(id) => Ok((
+
                id,
+
                patch
+
                    .revision(&id)
+
                    .ok_or_else(|| anyhow!("Patch revision `{id}` not found"))?,
+
            )),
+
            None => Ok((patch.latest().0, patch.latest().1)),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct ListFilter {
+
    state: Option<Status>,
+
    authored: bool,
+
    authors: Vec<Did>,
+
}
+

+
impl Default for ListFilter {
+
    fn default() -> Self {
+
        Self {
+
            state: Some(Status::Open),
+
            authored: false,
+
            authors: vec![],
+
        }
+
    }
+
}
+

+
impl ListFilter {
+
    pub fn is_default(&self) -> bool {
+
        *self == ListFilter::default()
+
    }
+

+
    pub fn with_state(mut self, status: Option<Status>) -> Self {
+
        self.state = status;
+
        self
+
    }
+

+
    pub fn with_authored(mut self, authored: bool) -> Self {
+
        self.authored = authored;
+
        self
+
    }
+

+
    pub fn with_author(mut self, author: Did) -> Self {
+
        self.authors.push(author);
+
        self
+
    }
+
}
+

+
#[allow(clippy::from_over_into)]
+
impl Into<PatchFilter> for (Did, ListFilter) {
+
    fn into(self) -> PatchFilter {
+
        let (me, mut filter) = self;
+
        let mut and = filter
+
            .state
+
            .map(|s| vec![PatchFilter::State(s)])
+
            .unwrap_or(vec![]);
+

+
        let mut dids = filter.authored.then_some(vec![me]).unwrap_or_default();
+
        dids.append(&mut filter.authors);
+

+
        if dids.len() == 1 {
+
            and.push(PatchFilter::Author(DidFilter::Single(
+
                *dids.first().unwrap(),
+
            )));
+
        } else if dids.len() > 1 {
+
            and.push(PatchFilter::Author(DidFilter::Or(dids)));
+
        }
+

+
        PatchFilter::And(and)
+
    }
+
}
+

+
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.clone());
+
        let mut op = OperationName::List;
+
        let mut forward = None;
+
        let mut json = false;
+
        let mut help = false;
+
        let mut edit = false;
+
        let mut repo = None;
+
        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') => {
+
                    help = true;
+
                    // Only enable forwarding if it was not already disabled explicitly
+
                    forward = match forward {
+
                        Some(false) => Some(false),
+
                        _ => Some(true),
+
                    };
+
                }
+

+
                Long("all") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(None);
+
                }
+
                Long("draft") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(Status::Draft));
+
                }
+
                Long("archived") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(Status::Archived));
+
                }
+
                Long("merged") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(Status::Merged));
+
                }
+
                Long("open") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(Status::Open));
+
                }
+
                Long("authored") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_authored(true);
+
                }
+
                Long("author") if op == OperationName::List => {
+
                    list_opts.filter = list_opts.filter.with_author(args::did(&parser.value()?)?);
+
                }
+
                Long("repo") => {
+
                    let val = parser.value()?;
+
                    let rid = args::rid(&val)?;
+

+
                    repo = Some(rid);
+
                }
+
                Long("revision") => {
+
                    let val = parser.value()?;
+
                    let rev_id = args::rev(&val)?;
+

+
                    revision_id = Some(rev_id);
+
                }
+
                Long("edit") => {
+
                    edit = true;
+
                }
+
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
+
                    "list" => op = OperationName::List,
+
                    _ => {
+
                        op = OperationName::Unknown;
+
                        // Only enable forwarding if it was not already disabled explicitly
+
                        forward = match forward {
+
                            Some(false) => Some(false),
+
                            _ => Some(true),
+
                        };
+
                    }
+
                },
+
                Value(val) if patch_id.is_none() => {
+
                    let val = string(&val);
+
                    patch_id = Some(Rev::from(val));
+
                }
+
                _ => match op {
+
                    OperationName::List | OperationName::Review => {
+
                        return Err(anyhow!(arg.unexpected()));
+
                    }
+
                    _ => {}
+
                },
+
            }
+
        }
+

+
        // 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
+
        list_opts.json = json;
+

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

+
        Ok((Options { op, repo }, vec![]))
+
    }
+
}
+

+
#[tokio::main]
+
pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) -> anyhow::Result<()> {
+
    use radicle::storage::ReadStorage;
+

+
    let (_, rid) = radicle::rad::cwd()
+
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+

+
    if let Err(err) = crate::log::enable() {
+
        println!("{err}");
+
    }
+

+
    match options.op {
+
        Operation::List { opts } => {
+
            log::info!("Starting patch listing app in project {rid}..");
+

+
            let rid = options.repo.unwrap_or(rid);
+
            interface::list(opts.clone(), ctx.profile()?, rid).await?;
+
        }
+
        Operation::Review { ref opts } => {
+
            log::info!("Starting patch review app in project {rid}..");
+

+
            let profile = ctx.profile()?;
+
            let rid = options.repo.unwrap_or(rid);
+
            let repo = profile.storage.repository(rid).unwrap();
+

+
            let patch_id: ObjectId = if let Some(patch_id) = &opts.patch_id {
+
                patch_id.resolve(&repo.backend)?
+
            } else {
+
                anyhow::bail!("a patch must be provided");
+
            };
+

+
            // Run TUI with patch review interface
+
            interface::review(opts.clone(), profile, rid, patch_id).await?;
+
        }
+
        Operation::Other { args } => {
+
            terminal::run_rad(Some("patch"), &args)?;
+
        }
+
        Operation::Unknown { .. } => {
+
            anyhow::bail!("unknown operation provided");
+
        }
+
    }
+

+
    Ok(())
+
}
+

+
mod interface {
+
    use anyhow::anyhow;
+

+
    use radicle::cob::patch::cache::Patches;
+
    use radicle::identity::RepoId;
+
    use radicle::patch;
+
    use radicle::patch::{PatchId, Verdict};
+
    use radicle::storage::git::cob::DraftStore;
+
    use radicle::storage::ReadStorage;
+
    use radicle::Profile;
+

+
    use crate::cob;
+
    use crate::terminal;
+
    use crate::tui_patch::list;
+
    use crate::tui_patch::review::builder::CommentBuilder;
+
    use crate::tui_patch::review::ReviewAction;
+
    use crate::tui_patch::review::ReviewMode;
+

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

+
    pub async fn list(opts: ListOptions, profile: Profile, rid: RepoId) -> anyhow::Result<()> {
+
        let me = profile.did();
+

+
        #[derive(Default)]
+
        struct PreviousState {
+
            patch_id: Option<PatchId>,
+
            search: Option<String>,
+
        }
+

+
        // Store issue and comment selection across app runs in order to
+
        // preselect them when re-running the app.
+
        let mut state = PreviousState::default();
+

+
        loop {
+
            let context = list::Context {
+
                profile: profile.clone(),
+
                rid,
+
                filter: (me, opts.filter.clone()).into(),
+
                search: state.search.clone(),
+
                patch_id: state.patch_id,
+
            };
+

+
            // Run TUI with patch list interface
+
            let selection = list::Tui::new(context).run().await?;
+

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

+
                log::info!("Exiting patch list app..");
+

+
                eprint!("{selection}");
+

+
                break;
+
            } else if let Some(selection) = selection {
+
                if let Some(operation) = selection.operation.clone() {
+
                    match operation {
+
                        list::PatchOperation::Show { args } => {
+
                            state = PreviousState {
+
                                patch_id: Some(args.id()),
+
                                search: Some(args.search()),
+
                            };
+
                            terminal::run_rad(
+
                                Some("patch"),
+
                                &["show".into(), args.id().to_string().into()],
+
                            )?;
+
                        }
+
                        list::PatchOperation::Diff { args } => {
+
                            let repo = profile.clone().storage.repository(rid)?;
+
                            let cache = profile.patches(&repo)?;
+
                            let patch = cache
+
                                .get(&args.id())?
+
                                .ok_or_else(|| anyhow!("unknown patch '{}'", args.id()))?;
+
                            let range = format!("{}..{}", patch.base(), patch.head());
+

+
                            state = PreviousState {
+
                                patch_id: Some(args.id()),
+
                                search: Some(args.search()),
+
                            };
+

+
                            let _ = terminal::run_git(Some("diff"), &[range.into()]);
+
                        }
+
                        list::PatchOperation::Checkout { args } => {
+
                            state = PreviousState {
+
                                patch_id: Some(args.id()),
+
                                search: Some(args.search()),
+
                            };
+
                            terminal::run_rad(
+
                                Some("patch"),
+
                                &["checkout".into(), args.id().to_string().into()],
+
                            )?;
+
                        }
+
                        list::PatchOperation::_Review { args } => {
+
                            state = PreviousState {
+
                                patch_id: Some(args.id()),
+
                                search: Some(args.search()),
+
                            };
+
                            let opts = ReviewOptions::default();
+
                            review(opts, profile.clone(), rid, args.id()).await?;
+
                        }
+
                    }
+
                }
+
            } else {
+
                break;
+
            }
+
        }
+

+
        Ok(())
+
    }
+

+
    pub async fn review(
+
        opts: ReviewOptions,
+
        profile: Profile,
+
        rid: RepoId,
+
        patch_id: PatchId,
+
    ) -> anyhow::Result<()> {
+
        use radicle_cli::terminal;
+

+
        let repo = profile.storage.repository(rid)?;
+
        let signer = terminal::signer(&profile)?;
+
        let cache = profile.patches(&repo)?;
+

+
        let patch = cache
+
            .get(&patch_id)?
+
            .ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;
+
        let (_, revision) = opts.revision_or_latest(&patch, &repo)?;
+
        let hunks = ReviewBuilder::new(&repo).hunks(revision)?;
+

+
        let drafts = DraftStore::new(&repo, *signer.public_key());
+
        let mut patches = patch::Cache::no_cache(&drafts)?;
+
        let mut patch = patches.get_mut(&patch_id)?;
+

+
        if let Some(review) = revision.review_by(signer.public_key()) {
+
            // Review already finalized. Do nothing and warn.
+
            terminal::warning(format!(
+
                "Review ({}) already finalized. Exiting.",
+
                review.id()
+
            ));
+

+
            return Ok(());
+
        };
+

+
        let mode = if opts.edit {
+
            if let Some((id, _)) = cob::find_review(&patch, revision, &signer) {
+
                // Review already started, resume.
+
                log::info!("Resuming review {id}..");
+

+
                ReviewMode::Edit { resume: true }
+
            } else {
+
                // No review to resume, start a new one.
+
                let id = patch.review(
+
                    revision.id(),
+
                    // This is amended before the review is finalized, if all hunks are
+
                    // accepted. We can't set this to `None`, as that will be invalid without
+
                    // a review summary.
+
                    Some(Verdict::Reject),
+
                    None,
+
                    vec![],
+
                    &signer,
+
                )?;
+
                log::info!("Starting new review {id}..");
+

+
                ReviewMode::Edit { resume: false }
+
            }
+
        } else {
+
            ReviewMode::Show
+
        };
+

+
        loop {
+
            // Reload review
+
            let signer = profile.signer()?;
+
            let (review_id, review) = cob::find_review(&patch, revision, &signer)
+
                .ok_or_else(|| anyhow!("Could not find review."))?;
+

+
            let response = review::Tui::new(
+
                mode.clone(),
+
                profile.storage.clone(),
+
                rid,
+
                patch_id,
+
                patch.title().to_string(),
+
                revision.clone(),
+
                review.clone(),
+
                hunks.clone(),
+
            )
+
            .run()
+
            .await?;
+

+
            log::debug!("Received response from TUI: {response:?}");
+

+
            if let Some(response) = response.as_ref() {
+
                if let Some(ReviewAction::Comment) = response.action {
+
                    let hunk = response
+
                        .state
+
                        .selected_hunk()
+
                        .ok_or_else(|| anyhow!("expected a selected hunk"))?;
+
                    let item = hunks
+
                        .get(hunk)
+
                        .ok_or_else(|| anyhow!("expected a hunk to comment on"))?;
+

+
                    let (old, new) = item.paths();
+
                    let path = old.or(new);
+

+
                    if let (Some(hunk), Some((path, _))) = (item.hunk(), path) {
+
                        let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
+
                        let comments = builder.edit(hunk)?;
+

+
                        let signer = profile.signer()?;
+
                        patch.transaction("Review comments", &signer, |tx| {
+
                            for comment in comments {
+
                                tx.review_comment(
+
                                    review_id,
+
                                    comment.body,
+
                                    Some(comment.location),
+
                                    None,   // Not a reply.
+
                                    vec![], // No embeds.
+
                                )?;
+
                            }
+
                            Ok(())
+
                        })?;
+
                    } else {
+
                        log::warn!("Commenting on binary blobs is not yet implemented");
+
                    }
+
                } else {
+
                    break;
+
                }
+
            } else {
+
                break;
+
            }
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
#[cfg(test)]
+
mod cli {
+
    use radicle_cli::terminal::args::Error;
+
    use radicle_cli::terminal::Args;
+

+
    use super::{ListOptions, Operation, Options};
+

+
    #[test]
+
    fn empty_operation_should_default_to_list_and_not_be_forwarded(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec![];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["--help".into(), "--no-forward".into()];
+

+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+
        assert!(matches!(actual, Error::Help));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn empty_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec!["--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec!["list".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_should_not_be_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let expected_op = Operation::List {
+
            opts: ListOptions::default(),
+
        };
+

+
        let args = vec!["list".into(), "--no-forward".into()];
+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--help".into()];
+
        let expected_op = Operation::Other { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
+
    {
+
        let args = vec!["list".into(), "--help".into(), "--no-forward".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+

+
        assert!(matches!(actual, Error::Help));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn list_operation_with_help_should_not_be_forwarded_reversed(
+
    ) -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["list".into(), "--no-forward".into(), "--help".into()];
+
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
+

+
        assert!(matches!(actual, Error::Help));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into()];
+
        let expected_op = Operation::Other { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn unknown_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
+
        let args = vec!["operation".into(), "--no-forward".into()];
+
        let expected_op = Operation::Unknown { args: args.clone() };
+

+
        let (actual, _) = Options::from_args(args)?;
+
        assert_eq!(actual.op, expected_op);
+

+
        Ok(())
+
    }
+
}
added bin/apps/patch/list.rs
@@ -0,0 +1,826 @@
+
use std::str::FromStr;
+
use std::sync::{Arc, Mutex};
+

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

+
use radicle::prelude::RepoId;
+
use radicle::storage::ReadStorage;
+
use ratatui::widgets::Clear;
+
use serde::Serialize;
+

+
use radicle::patch::cache::Patches;
+
use radicle::patch::PatchId;
+
use radicle::Profile;
+

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

+
use radicle_tui as tui;
+

+
use tui::event::Key;
+
use tui::store;
+
use tui::task::{Process, Task};
+
use tui::ui;
+
use tui::ui::layout::Spacing;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::{
+
    Borders, Column, ContainerState, TableState, TextEditState, TextViewState, Window,
+
};
+
use tui::ui::{BufferedValue, Show, Ui};
+
use tui::{Channel, Exit};
+

+
use crate::settings;
+
use crate::ui::items::filter::Filter;
+
use crate::ui::items::patch::filter::PatchFilter;
+
use crate::ui::items::patch::Patch;
+

+
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`:      Cancel
+
`q`:        Quit
+

+
# Specific keybindings
+

+
`enter`:    Show patch
+
`c`:        Checkout patch
+
`d`:        Show patch diff
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Examples:   state=open bugfix
+
            state=merged author=(did:key:... or did:key:...)"#;
+

+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub struct OperationArguments {
+
    id: PatchId,
+
    search: String,
+
}
+

+
impl OperationArguments {
+
    pub fn id(&self) -> PatchId {
+
        self.id
+
    }
+

+
    pub fn search(&self) -> String {
+
        self.search.clone()
+
    }
+
}
+

+
impl TryFrom<(&Vec<Patch>, &AppState)> for OperationArguments {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Vec<Patch>, &AppState)) -> Result<Self> {
+
        let (patches, state) = value;
+
        let selected = state.patches.selected();
+
        let id = selected
+
            .and_then(|s| patches.get(s))
+
            .ok_or(anyhow!("No patch selected"))?
+
            .id;
+
        let search = state.search.read().text;
+

+
        Ok(Self { id, search })
+
    }
+
}
+

+
/// The selected patch operation returned by the operation
+
/// selection widget.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub enum PatchOperation {
+
    Checkout { args: OperationArguments },
+
    Diff { args: OperationArguments },
+
    Show { args: OperationArguments },
+
    _Review { args: OperationArguments },
+
}
+

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

+
#[derive(Clone, Debug)]
+
pub struct Context {
+
    pub profile: Profile,
+
    pub rid: RepoId,
+
    pub filter: PatchFilter,
+
    pub patch_id: Option<PatchId>,
+
    pub search: Option<String>,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct Loader {
+
    context: Context,
+
}
+

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

+
#[derive(Debug)]
+
pub struct PatchLoader {
+
    context: Context,
+
}
+

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

+
impl Task for PatchLoader {
+
    type Return = Message;
+

+
    fn run(&self) -> anyhow::Result<Vec<Self::Return>> {
+
        let context = &self.context;
+
        let profile = context.profile.clone();
+
        let repo = profile.storage.repository(context.rid)?;
+
        let cache = profile.patches(&repo)?;
+
        let patches = cache
+
            .list()?
+
            .filter_map(|patch| patch.ok())
+
            .flat_map(|patch| Patch::new(&context.profile, &repo, patch.clone()).ok())
+
            .collect::<Vec<_>>();
+

+
        Ok(vec![Message::Loaded(patches)])
+
    }
+
}
+

+
impl Process<Message> for Loader {
+
    async fn process(&mut self, message: Message) -> anyhow::Result<Vec<Message>> {
+
        match message {
+
            Message::Initialize | Message::Reload => {
+
                let loader = PatchLoader::new(self.context.clone());
+
                let messages = tokio::spawn(async move { loader.run() }).await.unwrap()?;
+
                Ok(messages)
+
            }
+
            _ => Ok(vec![]),
+
        }
+
    }
+
}
+

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

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

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let viewport = Viewport::Inline(20);
+
        let channel = Channel::default();
+
        let state = App::try_from(&self.context)?;
+

+
        tui::im(
+
            state,
+
            viewport,
+
            channel,
+
            vec![Loader::new(self.context.clone())],
+
        )
+
        .await
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Change {
+
    Page {
+
        page: Page,
+
    },
+
    MainGroup {
+
        state: ContainerState,
+
    },
+
    Patches {
+
        state: TableState,
+
    },
+
    Search {
+
        search: BufferedValue<TextEditState>,
+
    },
+
    Help {
+
        state: TextViewState,
+
    },
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Initialize,
+
    Changed(Change),
+
    ShowSearch,
+
    HideSearch { apply: bool },
+
    Reload,
+
    Loaded(Vec<Patch>),
+
    Exit { operation: Option<PatchOperation> },
+
    Quit,
+
}
+

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

+
#[derive(Clone, Debug)]
+
pub struct AppState {
+
    page: Page,
+
    main_group: ContainerState,
+
    patches: TableState,
+
    search: BufferedValue<TextEditState>,
+
    show_search: bool,
+
    help: TextViewState,
+
    filter: PatchFilter,
+
    loading: bool,
+
    initialized: bool,
+
    theme: Theme,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct App {
+
    patches: Arc<Mutex<Vec<Patch>>>,
+
    state: AppState,
+
}
+

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

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

+
        let repo = &context.profile.storage.repository(context.rid)?;
+
        let cache = &context.profile.patches(repo)?;
+
        let mut patches = cache
+
            .list()?
+
            .filter_map(|patch| patch.ok())
+
            .flat_map(|patch| Patch::without_stats(&context.profile, patch.clone()).ok())
+
            .collect::<Vec<_>>();
+
        patches.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+

+
        let search = context.search.as_ref().map(|s| s.trim().to_string());
+
        let (search, filter) = match search {
+
            Some(search) => (
+
                search.clone(),
+
                PatchFilter::from_str(search.trim()).unwrap_or(PatchFilter::Invalid),
+
            ),
+
            None => {
+
                let filter = context.filter.clone();
+
                (filter.to_string().trim().to_string(), filter)
+
            }
+
        };
+

+
        Ok(App {
+
            patches: Arc::new(Mutex::new(patches.clone())),
+
            state: AppState {
+
                page: Page::Main,
+
                main_group: ContainerState::new(3, Some(0)),
+
                patches: TableState::new(Some(
+
                    context
+
                        .patch_id
+
                        .and_then(|id| {
+
                            patches
+
                                .iter()
+
                                .filter(|item| filter.matches(item))
+
                                .position(|item| item.id == id)
+
                        })
+
                        .unwrap_or(0),
+
                )),
+
                search: BufferedValue::new(TextEditState {
+
                    text: search.clone(),
+
                    cursor: search.len(),
+
                }),
+
                show_search: false,
+
                help: TextViewState::new(Position::default()),
+
                filter,
+
                loading: false,
+
                initialized: false,
+
                theme,
+
            },
+
        })
+
    }
+
}
+

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

+
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
+
        match message {
+
            Message::Initialize => {
+
                self.state.loading = true;
+
                self.state.initialized = true;
+
                None
+
            }
+
            Message::Reload => {
+
                self.state.loading = true;
+
                None
+
            }
+
            Message::Loaded(patches) => {
+
                self.apply_patches(patches);
+
                self.apply_sorting();
+
                self.state.loading = false;
+
                None
+
            }
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => Some(Exit {
+
                value: Some(Selection {
+
                    operation,
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::ShowSearch => {
+
                self.state.main_group = ContainerState::new(3, None);
+
                self.state.show_search = true;
+
                None
+
            }
+
            Message::HideSearch { apply } => {
+
                self.state.main_group = ContainerState::new(3, Some(0));
+
                self.state.show_search = false;
+

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

+
                self.state.filter = PatchFilter::from_str(&self.state.search.read().text)
+
                    .unwrap_or(PatchFilter::Invalid);
+

+
                None
+
            }
+
            Message::Changed(changed) => match changed {
+
                Change::Page { page } => {
+
                    self.state.page = page;
+
                    None
+
                }
+
                Change::MainGroup { state } => {
+
                    self.state.main_group = state;
+
                    None
+
                }
+
                Change::Patches { state } => {
+
                    self.state.patches = state;
+
                    None
+
                }
+
                Change::Search { search } => {
+
                    self.state.search = search;
+
                    self.state.filter = PatchFilter::from_str(&self.state.search.read().text)
+
                        .unwrap_or(PatchFilter::Invalid);
+
                    self.state.patches.select_first();
+
                    None
+
                }
+
                Change::Help { state } => {
+
                    self.state.help = state;
+
                    None
+
                }
+
            },
+
        }
+
    }
+
}
+

+
impl Show<Message> for App {
+
    fn show(&self, ctx: &ui::Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, self.state.theme.clone(), |ui| {
+
            // Initialize
+
            if !self.state.initialized {
+
                ui.send_message(Message::Initialize);
+
            }
+

+
            match self.state.page {
+
                Page::Main => {
+
                    let show_search = self.state.show_search;
+
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
+

+
                    ui.container(
+
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
+
                        &mut page_focus,
+
                        |ui| {
+
                            let mut group_focus = self.state.main_group.focus();
+

+
                            let group = ui.container(
+
                                ui::Layout::Expandable3 { left_only: true },
+
                                &mut group_focus,
+
                                |ui| {
+
                                    self.show_browser(frame, ui);
+
                                },
+
                            );
+
                            if group.response.changed {
+
                                ui.send_message(Message::Changed(Change::MainGroup {
+
                                    state: ContainerState::new(3, group_focus),
+
                                }));
+
                            }
+

+
                            if show_search {
+
                                self.show_browser_search(frame, ui);
+
                            } else if let Some(0) = group_focus {
+
                                self.show_browser_footer(frame, ui);
+
                            }
+
                        },
+
                    );
+

+
                    if !show_search && ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
+
                    }
+
                }
+

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

+
                    ui.container(layout, &mut Some(1), |ui| {
+
                        self.show_help_text(frame, ui);
+
                        self.show_help_context(frame, ui);
+

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

+
                    if ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page { page: Page::Main }));
+
                    }
+
                }
+
            }
+
            if ui.has_input(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
            if ui.has_input(|key| key == Key::Ctrl('c')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
+
}
+

+
impl App {
+
    pub fn show_browser(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        let patches = self.patches.lock().unwrap();
+
        let patches = patches
+
            .iter()
+
            .filter(|patch| self.state.filter.matches(patch))
+
            .cloned()
+
            .collect::<Vec<_>>();
+
        let mut selected = self.state.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(),
+
        ];
+

+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.column_bar(frame, header.to_vec(), Spacing::from(1), Some(Borders::Top));
+

+
                let table = ui.table(
+
                    frame,
+
                    &mut selected,
+
                    &patches,
+
                    header.to_vec(),
+
                    Some("No patches found".into()),
+
                    Spacing::from(1),
+
                    Some(Borders::BottomSides),
+
                );
+
                if table.changed {
+
                    ui.send_message(Message::Changed(Change::Patches {
+
                        state: TableState::new(selected),
+
                    }));
+
                }
+

+
                if self.state.loading {
+
                    self.show_loading_popup(frame, ui);
+
                }
+
            },
+
        );
+

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

+
        if let Ok(args) = OperationArguments::try_from((&patches, &self.state)) {
+
            if ui.has_input(|key| key == Key::Enter) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(PatchOperation::Show { args: args.clone() }),
+
                });
+
            }
+
            if ui.has_input(|key| key == Key::Char('d')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(PatchOperation::Diff { args: args.clone() }),
+
                });
+
            }
+
            if ui.has_input(|key| key == Key::Char('c')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(PatchOperation::Checkout { args }),
+
                });
+
            }
+
        }
+
    }
+

+
    fn show_browser_footer(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.layout(Layout::vertical([1, 1]), None, |ui| {
+
            self.show_browser_context(frame, ui);
+
            self.show_browser_shortcuts(frame, ui);
+
        });
+
    }
+

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

+
        let text_edit = ui.text_edit_singleline(
+
            frame,
+
            &mut search_text,
+
            &mut search_cursor,
+
            Some("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::Changed(Change::Search { search }));
+
        }
+

+
        if ui.has_input(|key| key == Key::Esc) {
+
            ui.send_message(Message::HideSearch { apply: false });
+
        }
+
        if ui.has_input(|key| key == Key::Enter) {
+
            ui.send_message(Message::HideSearch { apply: true });
+
        }
+
    }
+

+
    fn show_browser_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        let context = {
+
            let patches = self.patches.lock().unwrap();
+
            let search = self.state.search.read().text;
+
            let total_count = patches.len();
+
            let filtered_count = patches
+
                .iter()
+
                .filter(|patch| self.state.filter.matches(patch))
+
                .collect::<Vec<_>>()
+
                .len();
+

+
            let filtered_counts = format!(" {filtered_count}/{total_count} ");
+
            let state_counts =
+
                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 !self.state.filter.has_state() {
+
                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)
+
                            .cyan()
+
                            .dim(),
+
                        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)
+
                            .cyan()
+
                            .dim(),
+
                        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()
+
            }
+
        };
+

+
        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
+
    }
+

+
    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.shortcuts(
+
            frame,
+
            &[
+
                ("enter", "show"),
+
                ("c", "checkout"),
+
                ("d", "diff"),
+
                ("/", "search"),
+
                ("r", "reload"),
+
                ("?", "help"),
+
            ],
+
            '∙',
+
            Alignment::Left,
+
        );
+
    }
+

+
    fn show_loading_popup(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.popup(Layout::vertical([Constraint::Min(1)]), |ui| {
+
            ui.layout(
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).margin(1),
+
                None,
+
                |ui| {
+
                    ui.label(frame, "");
+
                    ui.layout(
+
                        Layout::horizontal([Constraint::Min(1), Constraint::Length(11)]),
+
                        None,
+
                        |ui| {
+
                            ui.label(frame, "");
+
                            ui.layout(Layout::vertical([Constraint::Min(1)]), None, |ui| {
+
                                frame.render_widget(Clear, ui.area());
+
                                ui.column_bar(
+
                                    frame,
+
                                    [Column::new(
+
                                        Span::raw(" Loading ").magenta().rapid_blink(),
+
                                        Constraint::Fill(1),
+
                                    )]
+
                                    .to_vec(),
+
                                    Spacing::from(0),
+
                                    Some(Borders::All),
+
                                );
+
                            });
+
                        },
+
                    );
+
                },
+
            );
+
        });
+
    }
+

+
    fn show_help_text(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.column_bar(
+
            frame,
+
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::Top),
+
        );
+

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

+
    fn show_help_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
+
        ui.column_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(),
+
            Spacing::from(0),
+
            Some(Borders::None),
+
        );
+
    }
+
}
+

+
impl App {
+
    fn apply_patches(&mut self, patches: Vec<Patch>) {
+
        let mut items = self.patches.lock().unwrap();
+
        *items = patches;
+
    }
+

+
    fn apply_sorting(&mut self) {
+
        let mut items = self.patches.lock().unwrap();
+
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+
    }
+
}
added bin/apps/patch/review.rs
@@ -0,0 +1,974 @@
+
#[path = "review/builder.rs"]
+
pub mod builder;
+

+
use std::fmt::Debug;
+
use std::sync::{Arc, Mutex};
+

+
use anyhow::Result;
+

+
use serde::{Deserialize, Serialize};
+

+
use ratatui::layout::{Alignment, Constraint, Layout, Position};
+
use ratatui::style::{Style, Stylize};
+
use ratatui::text::Text;
+
use ratatui::{Frame, Viewport};
+

+
use radicle::identity::RepoId;
+
use radicle::patch::{PatchId, Review, Revision, RevisionId};
+
use radicle::storage::ReadStorage;
+
use radicle::Storage;
+

+
use radicle_tui as tui;
+

+
use tui::event::Key;
+
use tui::store;
+
use tui::task::EmptyProcessors;
+
use tui::ui::layout::Spacing;
+
use tui::ui::span;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::{Borders, Column, ContainerState, TableState, TextViewState, Window};
+
use tui::ui::{Context, Show, Ui};
+
use tui::{Channel, Exit};
+

+
use crate::git::HunkState;
+
use crate::settings;
+
use crate::state::{self, FileIdentifier, FileStore, ReadState, WriteState};
+
use crate::ui::format;
+
use crate::ui::items::patch::{HunkItem, StatefulHunkItem};
+
use crate::ui::layout;
+

+
use self::builder::Hunks;
+

+
/// The actions that a user can carry out on a review item.
+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+
pub enum ReviewAction {
+
    Comment,
+
}
+

+
#[allow(dead_code)]
+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct Args(String);
+

+
#[derive(Clone, Debug)]
+
pub struct Response {
+
    pub state: AppState,
+
    pub action: Option<ReviewAction>,
+
}
+

+
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+
pub enum ReviewMode {
+
    Show,
+
    Edit { resume: bool },
+
}
+

+
pub struct Tui {
+
    pub mode: ReviewMode,
+
    pub storage: Storage,
+
    pub rid: RepoId,
+
    pub patch: PatchId,
+
    pub title: String,
+
    pub revision: Revision,
+
    pub review: Review,
+
    pub hunks: Hunks,
+
}
+

+
impl Tui {
+
    #[allow(clippy::too_many_arguments)]
+
    pub fn new(
+
        mode: ReviewMode,
+
        storage: Storage,
+
        rid: RepoId,
+
        patch: PatchId,
+
        title: String,
+
        revision: Revision,
+
        review: Review,
+
        hunks: Hunks,
+
    ) -> Self {
+
        Self {
+
            mode,
+
            storage,
+
            rid,
+
            patch,
+
            title,
+
            revision,
+
            review,
+
            hunks,
+
        }
+
    }
+

+
    pub async fn run(self) -> Result<Option<Response>> {
+
        let viewport = Viewport::Fullscreen;
+
        let channel = Channel::default();
+

+
        let identifier = FileIdentifier::new("patch", "review", &self.rid, Some(&self.patch));
+
        let store = FileStore::new(identifier)?;
+

+
        let default = AppState::new(
+
            ReviewMode::Show,
+
            self.rid,
+
            self.patch,
+
            self.title,
+
            self.revision.id(),
+
            &self.hunks,
+
        );
+

+
        let state = store
+
            .read()
+
            .map(|bytes| state::from_json::<AppState>(&bytes).ok())?
+
            .unwrap_or(default);
+

+
        let app = App::new(self.storage, self.review, self.hunks, state, self.mode)?;
+
        let response = tui::im(app, viewport, channel, EmptyProcessors::new()).await?;
+

+
        if let Some(response) = response.as_ref() {
+
            store.write(&state::to_json(&response.state)?)?;
+
        }
+

+
        Ok(response)
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    ShowMain,
+
    PanesChanged { state: ContainerState },
+
    HunkChanged { state: TableState },
+
    HunkViewChanged { state: DiffViewState },
+
    ShowHelp,
+
    HelpChanged { state: TextViewState },
+
    Comment,
+
    Accept,
+
    Reject,
+
    Quit,
+
}
+

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

+
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
+
pub struct DiffViewState {
+
    cursor: Position,
+
}
+

+
#[derive(Clone, Debug, Serialize, Deserialize)]
+
pub struct AppState {
+
    /// Review mode: edit or show.
+
    mode: ReviewMode,
+
    /// The repository to operate on.
+
    rid: RepoId,
+
    /// Patch this review belongs to.
+
    patch: PatchId,
+
    /// Patch title.
+
    title: String,
+
    /// Revision this review belongs to.
+
    revision: RevisionId,
+
    /// Current app page.
+
    page: AppPage,
+
    /// State of panes widget on the main page.
+
    panes: ContainerState,
+
    /// The hunks' table widget state.
+
    hunks: (TableState, Vec<HunkState>),
+
    /// Diff view states (cursor position is stored per hunk)
+
    views: Vec<DiffViewState>,
+
    /// State of text view widget on the help page.
+
    help: TextViewState,
+
    /// The active theme
+
    theme: Theme,
+
}
+

+
impl AppState {
+
    pub fn new(
+
        mode: ReviewMode,
+
        rid: RepoId,
+
        patch: PatchId,
+
        title: String,
+
        revision: RevisionId,
+
        hunks: &Hunks,
+
    ) -> Self {
+
        let settings = settings::Settings::default();
+
        let theme = settings::configure_theme(&settings);
+

+
        Self {
+
            mode,
+
            rid,
+
            patch,
+
            title,
+
            revision,
+
            page: AppPage::Main,
+
            panes: ContainerState::new(2, Some(0)),
+
            hunks: (
+
                TableState::new(Some(0)),
+
                vec![HunkState::Rejected; hunks.len()],
+
            ),
+
            views: vec![DiffViewState::default(); hunks.len()],
+
            help: TextViewState::new(Position::default()),
+
            theme,
+
        }
+
    }
+

+
    pub fn view_state(&self, index: usize) -> Option<&DiffViewState> {
+
        self.views.get(index)
+
    }
+

+
    pub fn update_view_state(&mut self, index: usize, state: DiffViewState) {
+
        if let Some(view) = self.views.get_mut(index) {
+
            *view = state;
+
        }
+
    }
+

+
    pub fn update_hunks(&mut self, hunks: TableState) {
+
        self.hunks.0 = hunks;
+
    }
+

+
    pub fn selected_hunk(&self) -> Option<usize> {
+
        self.hunks.0.selected()
+
    }
+

+
    pub fn accept_hunk(&mut self, index: usize) {
+
        if let Some(state) = self.hunks.1.get_mut(index) {
+
            *state = HunkState::Accepted;
+
        }
+
    }
+

+
    pub fn reject_hunk(&mut self, index: usize) {
+
        if let Some(state) = self.hunks.1.get_mut(index) {
+
            *state = HunkState::Rejected;
+
        }
+
    }
+

+
    pub fn hunk_states(&self) -> &Vec<HunkState> {
+
        &self.hunks.1
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct App<'a> {
+
    /// All hunks.
+
    hunks: Arc<Mutex<Vec<StatefulHunkItem<'a>>>>,
+
    /// The app state.
+
    state: Arc<Mutex<AppState>>,
+
}
+

+
impl App<'_> {
+
    pub fn new(
+
        storage: Storage,
+
        review: Review,
+
        hunks: Hunks,
+
        state: AppState,
+
        mode: ReviewMode,
+
    ) -> Result<Self, anyhow::Error> {
+
        let repo = storage.repository(state.rid)?;
+
        // TODO: Check, if it's necessary to protect the app state.
+
        // let mode = match state.mode {
+
        //     ReviewMode::Edit { resume: _ } if mode == ReviewMode::Show => {
+
        //         // TODO: Ask user what to do.
+
        //         anyhow::bail!("Review not finalized, yet. Current state would be lost.")
+
        //     }
+
        //     _ => mode,
+
        // };
+

+
        let hunks = hunks
+
            .iter()
+
            .enumerate()
+
            .map(|(idx, item)| {
+
                StatefulHunkItem::new(
+
                    HunkItem::from((&repo, &review, item)),
+
                    state.hunk_states().get(idx).cloned().unwrap_or_default(),
+
                )
+
            })
+
            .collect::<Vec<_>>();
+

+
        Ok(Self {
+
            hunks: Arc::new(Mutex::new(hunks)),
+
            state: Arc::new(Mutex::new(AppState { mode, ..state })),
+
        })
+
    }
+

+
    pub fn accept_selected_hunk(&mut self) -> Result<()> {
+
        if let Some(selected) = self.selected_hunk() {
+
            let mut state = self.state.lock().unwrap();
+
            state.accept_hunk(selected);
+
        }
+
        self.synchronize_hunk_state();
+

+
        Ok(())
+
    }
+

+
    pub fn reject_selected_hunk(&mut self) -> Result<()> {
+
        if let Some(selected) = self.selected_hunk() {
+
            let mut state = self.state.lock().unwrap();
+
            state.reject_hunk(selected);
+
        }
+
        self.synchronize_hunk_state();
+

+
        Ok(())
+
    }
+

+
    pub fn selected_hunk(&self) -> Option<usize> {
+
        let state = self.state.lock().unwrap();
+
        state.selected_hunk()
+
    }
+

+
    fn synchronize_hunk_state(&mut self) {
+
        let state = self.state.lock().unwrap();
+
        let mut hunks = self.hunks.lock().unwrap();
+

+
        if let Some(selected) = state.selected_hunk() {
+
            if let Some(item) = hunks.get_mut(selected) {
+
                if let Some(state) = state.hunk_states().get(selected) {
+
                    item.update_state(state);
+
                }
+
            }
+
        }
+
    }
+
}
+

+
impl App<'_> {
+
    fn show_hunk_list(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
+
        let hunks = self.hunks.lock().unwrap();
+
        let state = self.state.lock().unwrap();
+

+
        let state_column_width = match state.mode {
+
            ReviewMode::Show => 0,
+
            ReviewMode::Edit { resume: _ } => 2,
+
        };
+
        let header = [Column::new(" Hunks ", Constraint::Fill(1))].to_vec();
+
        let columns = [
+
            Column::new("", Constraint::Length(state_column_width)),
+
            Column::new("", Constraint::Fill(1)),
+
            Column::new("", Constraint::Length(15)),
+
        ]
+
        .to_vec();
+

+
        let mut selected = state.selected_hunk();
+

+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            Some(1),
+
            |ui| {
+
                ui.column_bar(
+
                    frame,
+
                    header.to_vec(),
+
                    Spacing::default(),
+
                    Some(Borders::Top),
+
                );
+

+
                let table = ui.table(
+
                    frame,
+
                    &mut selected,
+
                    &hunks,
+
                    columns,
+
                    None,
+
                    Spacing::from(1),
+
                    Some(Borders::BottomSides),
+
                );
+
                if table.changed {
+
                    ui.send_message(Message::HunkChanged {
+
                        state: TableState::new(selected),
+
                    })
+
                }
+
            },
+
        );
+
    }
+

+
    fn show_hunk(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
+
        let hunks = self.hunks.lock().unwrap();
+
        let state = self.state.lock().unwrap();
+

+
        let selected = state.selected_hunk();
+
        let hunk = selected.and_then(|selected| hunks.get(selected));
+

+
        if let Some(hunk) = hunk {
+
            let mut cursor = selected
+
                .and_then(|selected| state.view_state(selected))
+
                .map(|state| state.cursor)
+
                .unwrap_or_default();
+

+
            ui.container(layout::container(), &mut Some(1), |ui| {
+
                ui.column_bar(
+
                    frame,
+
                    hunk.inner().header(),
+
                    Spacing::from(0),
+
                    Some(Borders::Top),
+
                );
+

+
                if let Some(text) = hunk.inner().hunk_text() {
+
                    let diff = ui.text_view(frame, text, &mut cursor, Some(Borders::BottomSides));
+
                    if diff.changed {
+
                        ui.send_message(Message::HunkViewChanged {
+
                            state: DiffViewState { cursor },
+
                        })
+
                    }
+
                } else {
+
                    let empty_text = hunk
+
                        .inner()
+
                        .hunk_text()
+
                        .unwrap_or(Text::raw("Nothing to show.").dark_gray());
+
                    ui.centered_text_view(frame, empty_text, Some(Borders::BottomSides));
+
                }
+
            });
+
        }
+
    }
+

+
    fn show_context_bar(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
+
        let hunks = &self.hunks.lock().unwrap();
+
        let state = self.state.lock().unwrap();
+

+
        let id = format!(" {} ", format::cob(&state.patch));
+
        let title = &state.title;
+

+
        let hunks_total = hunks.len();
+
        let hunks_accepted = state
+
            .hunks
+
            .1
+
            .iter()
+
            .filter(|state| **state == HunkState::Accepted)
+
            .collect::<Vec<_>>()
+
            .len();
+

+
        let (mode, context, context_style) = match state.mode {
+
            ReviewMode::Show => (
+
                " Show ",
+
                "".into(),
+
                Style::default().cyan().dim().reversed(),
+
            ),
+
            ReviewMode::Edit { resume: _ } => (
+
                " Edit ",
+
                format!(" Accepted {hunks_accepted}/{hunks_total} "),
+
                Style::default().light_red().dim().reversed(),
+
            ),
+
        };
+

+
        ui.column_bar(
+
            frame,
+
            [
+
                Column::new(
+
                    span::default(mode).style(context_style),
+
                    Constraint::Length(mode.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    span::default(&id)
+
                        .style(ui.theme().bar_on_black_style)
+
                        .magenta(),
+
                    Constraint::Length(9),
+
                ),
+
                Column::new(
+
                    span::default(title)
+
                        .style(ui.theme().bar_on_black_style)
+
                        .magenta()
+
                        .dim(),
+
                    Constraint::Length(title.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    span::default(" ")
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    span::default(&context)
+
                        .into_right_aligned_line()
+
                        .style(context_style),
+
                    Constraint::Length(context.chars().count() as u16),
+
                ),
+
            ]
+
            .to_vec(),
+
            Spacing::from(0),
+
            Some(Borders::None),
+
        );
+
    }
+

+
    fn show_footer(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
+
        let state = self.state.lock().unwrap();
+
        match state.mode {
+
            ReviewMode::Edit { resume: _ } => {
+
                ui.shortcuts(
+
                    frame,
+
                    &[
+
                        ("c", "comment"),
+
                        ("a", "accept"),
+
                        ("r", "reject"),
+
                        ("?", "help"),
+
                        ("q", "quit"),
+
                    ],
+
                    '∙',
+
                    Alignment::Left,
+
                );
+

+
                if ui.has_input(|key| key == Key::Char('?')) {
+
                    ui.send_message(Message::ShowHelp);
+
                }
+
                if ui.has_input(|key| key == Key::Char('c')) {
+
                    ui.send_message(Message::Comment);
+
                }
+
                if ui.has_input(|key| key == Key::Char('a')) {
+
                    ui.send_message(Message::Accept);
+
                }
+
                if ui.has_input(|key| key == Key::Char('r')) {
+
                    ui.send_message(Message::Reject);
+
                }
+
            }
+
            ReviewMode::Show => {
+
                ui.shortcuts(frame, &[("?", "help"), ("q", "quit")], '∙', Alignment::Left);
+

+
                if ui.has_input(|key| key == Key::Char('?')) {
+
                    ui.send_message(Message::ShowHelp);
+
                }
+
            }
+
        }
+
    }
+
}
+

+
impl Show<Message> for App<'_> {
+
    fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<(), anyhow::Error> {
+
        let (page, theme) = {
+
            let state = self.state.lock().unwrap();
+
            (state.page.clone(), state.theme.clone())
+
        };
+

+
        Window::default().show(ctx, theme, |ui| {
+
            match page {
+
                AppPage::Main => {
+
                    let (mut focus, count) = {
+
                        let state = self.state.lock().unwrap();
+
                        (state.panes.focus(), state.panes.len())
+
                    };
+

+
                    ui.layout(layout::page(), Some(0), |ui| {
+
                        let group = ui.container(layout::list_item(), &mut focus, |ui| {
+
                            self.show_hunk_list(ui, frame);
+
                            self.show_hunk(ui, frame);
+
                        });
+
                        if group.response.changed {
+
                            ui.send_message(Message::PanesChanged {
+
                                state: ContainerState::new(count, focus),
+
                            });
+
                        }
+

+
                        self.show_context_bar(ui, frame);
+
                        self.show_footer(ui, frame);
+
                    });
+
                }
+
                AppPage::Help => {
+
                    ui.layout(layout::page(), Some(0), |ui| {
+
                        ui.container(layout::container(), &mut Some(1), |ui| {
+
                            let mut cursor = {
+
                                let state = self.state.lock().unwrap();
+
                                state.help.cursor()
+
                            };
+
                            let header = [Column::new(" Help ", Constraint::Fill(1))].to_vec();
+

+
                            ui.column_bar(frame, header, Spacing::from(0), Some(Borders::Top));
+
                            let help = ui.text_view(
+
                                frame,
+
                                help_text().to_string(),
+
                                &mut cursor,
+
                                Some(Borders::BottomSides),
+
                            );
+
                            if help.changed {
+
                                ui.send_message(Message::HelpChanged {
+
                                    state: TextViewState::new(cursor),
+
                                })
+
                            }
+
                        });
+

+
                        self.show_context_bar(ui, frame);
+

+
                        ui.shortcuts(
+
                            frame,
+
                            &[("?", "close"), ("q", "quit")],
+
                            '∙',
+
                            Alignment::Left,
+
                        );
+
                    });
+

+
                    if ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::ShowMain);
+
                    }
+
                }
+
            }
+

+
            if ui.has_input(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+
        Ok(())
+
    }
+
}
+

+
impl store::Update<Message> for App<'_> {
+
    type Return = Response;
+

+
    fn update(&mut self, message: Message) -> Option<Exit<Self::Return>> {
+
        match message {
+
            Message::ShowMain => {
+
                let mut state = self.state.lock().unwrap();
+
                state.page = AppPage::Main;
+
                None
+
            }
+
            Message::ShowHelp => {
+
                let mut state = self.state.lock().unwrap();
+
                state.page = AppPage::Help;
+
                None
+
            }
+
            Message::PanesChanged { state } => {
+
                let mut app_state = self.state.lock().unwrap();
+
                app_state.panes = state;
+
                None
+
            }
+
            Message::HunkChanged { state } => {
+
                let mut app_state = self.state.lock().unwrap();
+
                app_state.update_hunks(state);
+
                None
+
            }
+
            Message::HunkViewChanged { state } => {
+
                let mut app_state = self.state.lock().unwrap();
+
                if let Some(selected) = app_state.selected_hunk() {
+
                    app_state.update_view_state(selected, state);
+
                }
+
                None
+
            }
+
            Message::HelpChanged { state } => {
+
                let mut app_state = self.state.lock().unwrap();
+
                app_state.help = state;
+
                None
+
            }
+
            Message::Comment => {
+
                let state = self.state.lock().unwrap();
+
                Some(Exit {
+
                    value: Some(Response {
+
                        action: Some(ReviewAction::Comment),
+
                        state: state.clone(),
+
                    }),
+
                })
+
            }
+
            Message::Accept => {
+
                match self.accept_selected_hunk() {
+
                    Ok(()) => log::debug!("Accepted selected hunk ({:?}).", self.selected_hunk()),
+
                    Err(err) => log::error!("An error occured while accepting hunk: {err}"),
+
                }
+
                None
+
            }
+
            Message::Reject => {
+
                match self.reject_selected_hunk() {
+
                    Ok(()) => log::debug!("Rejected selected hunk ({:?}).", self.selected_hunk()),
+
                    Err(err) => log::error!("An error occured while rejecting hunk: {err}"),
+
                }
+
                None
+
            }
+
            Message::Quit => {
+
                let state = self.state.lock().unwrap();
+
                Some(Exit {
+
                    value: Some(Response {
+
                        action: None,
+
                        state: state.clone(),
+
                    }),
+
                })
+
            }
+
        }
+
    }
+
}
+

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

+
A terminal interface for reviewing patch revisions.
+

+
Starts a new or resumes an existing review for a given revision (default: latest). When the
+
review is done, it needs to be finalized via `rad patch review --accept | --reject <id>`.
+

+
# Keybindings
+

+
`←,h`       move cursor to the left
+
`↑,k`       move cursor one line up
+
`↓,j`       move cursor one line down
+
`→,l`       move cursor to the right
+
`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 pane
+
`BackTab`   Focus previous pane
+

+
`?`         toogle help
+
`q`         quit / cancel
+

+
## Specific keybindings
+

+
`c`         comment on hunk
+
`a`         accept hunk
+
`d`         discard accepted hunks (reject all)"#
+
        .into()
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use anyhow::*;
+

+
    use radicle::patch::Cache;
+

+
    use store::Update;
+

+
    use super::*;
+
    use crate::test;
+

+
    impl App<'_> {
+
        pub fn hunks(&self) -> Vec<StatefulHunkItem<'_>> {
+
            self.hunks.lock().unwrap().clone()
+
        }
+
    }
+

+
    mod fixtures {
+
        use anyhow::*;
+

+
        use radicle::cob::cache::NoCache;
+
        use radicle::patch::{Cache, PatchMut, Review, ReviewId, Revision, Verdict};
+
        use radicle::storage::git::cob::DraftStore;
+
        use radicle::storage::git::Repository;
+

+
        use crate::cob;
+
        use crate::test::setup::NodeWithRepo;
+

+
        use super::builder::ReviewBuilder;
+
        use super::{App, AppState, ReviewMode};
+

+
        pub fn app<'a>(
+
            node: &NodeWithRepo,
+
            patch: PatchMut<Repository, NoCache>,
+
        ) -> Result<App<'a>> {
+
            let draft_store = DraftStore::new(&node.repo.repo, *node.signer.public_key());
+
            let mut drafts = Cache::no_cache(&draft_store)?;
+
            let mut draft = drafts.get_mut(patch.id())?;
+

+
            let (_, revision) = patch.latest();
+
            let (_, review) = draft_review(node, &mut draft, revision)?;
+

+
            let hunks = ReviewBuilder::new(&node.repo).hunks(revision)?;
+

+
            let mode = ReviewMode::Edit { resume: false };
+
            let state = AppState::new(
+
                mode.clone(),
+
                node.repo.id,
+
                *patch.id(),
+
                patch.title().to_string(),
+
                revision.id(),
+
                &hunks,
+
            );
+

+
            App::new(node.storage.clone(), review.clone(), hunks, state, mode)
+
        }
+

+
        pub fn draft_review<'a>(
+
            node: &NodeWithRepo,
+
            draft: &'a mut PatchMut<DraftStore<Repository>, NoCache>,
+
            revision: &Revision,
+
        ) -> Result<(ReviewId, &'a Review)> {
+
            let id = draft.review(
+
                revision.id(),
+
                Some(Verdict::Reject),
+
                None,
+
                vec![],
+
                &node.node.signer,
+
            )?;
+

+
            let (_, review) = cob::find_review(draft, revision, &node.node.signer)
+
                .ok_or_else(|| anyhow!("Could not find review."))?;
+

+
            Ok((id, review))
+
        }
+
    }
+

+
    #[test]
+
    fn app_with_single_hunk_can_be_constructed() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_main_emptied(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let app = fixtures::app(&alice, patch)?;
+

+
        assert_eq!(app.hunks().len(), 1);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn app_with_single_file_multiple_hunks_can_be_constructed() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_eof_removed(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let app = fixtures::app(&alice, patch)?;
+

+
        assert_eq!(app.hunks().len(), 2);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn first_hunk_is_selected_by_default() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_main_emptied(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let app = fixtures::app(&alice, patch)?;
+

+
        assert_eq!(app.selected_hunk(), Some(0));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn hunks_are_rejected_by_default() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_main_deleted_and_file_added(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let app = fixtures::app(&alice, patch)?;
+
        let state = app.state.lock().unwrap();
+
        let states = &state.hunk_states();
+

+
        assert_eq!(**states, [HunkState::Rejected, HunkState::Rejected]);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn hunk_can_be_selected() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_eof_removed(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let mut app = fixtures::app(&alice, patch)?;
+
        app.update(Message::HunkChanged {
+
            state: TableState::new(Some(1)),
+
        });
+

+
        assert_eq!(app.selected_hunk(), Some(1));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn single_file_single_hunk_can_be_accepted() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_main_emptied(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let mut app = fixtures::app(&alice, patch)?;
+
        app.update(Message::Accept);
+

+
        let state = app.state.lock().unwrap();
+
        let state = &state.hunk_states().first().unwrap();
+

+
        assert_eq!(**state, HunkState::Accepted);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn single_file_multiple_hunks_only_first_can_be_accepted() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_main_changed(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let mut app = fixtures::app(&alice, patch)?;
+
        app.update(Message::Accept);
+

+
        let state = app.state.lock().unwrap();
+
        let states = &state.hunk_states();
+

+
        assert_eq!(**states, [HunkState::Accepted, HunkState::Rejected]);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn single_file_multiple_hunks_only_last_can_be_accepted() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_main_changed(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let mut app = fixtures::app(&alice, patch)?;
+

+
        app.update(Message::HunkChanged {
+
            state: TableState::new(Some(1)),
+
        });
+
        app.update(Message::Accept);
+

+
        let state = app.state.lock().unwrap();
+
        let states = &state.hunk_states();
+

+
        assert_eq!(**states, [HunkState::Rejected, HunkState::Accepted]);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn multiple_files_single_hunk_can_be_accepted() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_main_deleted_and_file_added(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let mut app = fixtures::app(&alice, patch)?;
+
        app.update(Message::Accept);
+

+
        app.update(Message::HunkChanged {
+
            state: TableState::new(Some(1)),
+
        });
+
        app.update(Message::Accept);
+

+
        let state = app.state.lock().unwrap();
+
        let states = &state.hunk_states();
+

+
        assert_eq!(**states, [HunkState::Accepted, HunkState::Accepted]);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn hunk_state_is_synchronized() -> Result<()> {
+
        let alice = test::fixtures::node_with_repo();
+
        let branch = test::fixtures::branch_with_main_changed(&alice);
+

+
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
+
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
+

+
        let mut app = fixtures::app(&alice, patch)?;
+
        app.update(Message::Accept);
+

+
        let state = app.state.lock().unwrap();
+
        let hunks = app.hunks.lock().unwrap();
+

+
        let item_states = hunks
+
            .iter()
+
            .map(|item| item.state().clone())
+
            .collect::<Vec<_>>();
+
        let states = &state.hunk_states();
+

+
        assert_eq!(**states, item_states);
+

+
        Ok(())
+
    }
+
}
added bin/apps/patch/review/builder.rs
@@ -0,0 +1,694 @@
+
//! Review builder.
+
//!
+
//! This module enables a user to review a patch by interactively viewing and accepting diff hunks.
+
//!
+
//! To implement this behavior, we keep a hidden Git tree object that tracks the state of the
+
//! repository including the accepted hunks. Thus, every time a diff hunk is accepted, it is applied
+
//! to that tree. We call that tree the "brain", as it tracks what the code reviewer has reviewed.
+
//!
+
//! The brain starts out equalling the tree of the base branch, and eventually, when the brain
+
//! matches the tree of the patch being reviewed (by accepting hunks), we can say that the patch has
+
//! been fully reviewed.
+
//!
+
use std::fmt::Write as _;
+
use std::io;
+
use std::ops::{Not, Range};
+
use std::path::PathBuf;
+

+
use radicle::cob::patch::Revision;
+
use radicle::cob::{CodeLocation, CodeRange};
+
use radicle::git;
+
use radicle::git::Oid;
+
use radicle::prelude::*;
+
use radicle::storage::git::Repository;
+
use radicle_surf::diff::*;
+

+
use radicle_cli::git::unified_diff::{self, FileHeader};
+
use radicle_cli::git::unified_diff::{Encode, HunkHeader};
+
use radicle_cli::terminal as term;
+

+
use crate::git::HunkDiff;
+

+
/// Queue of items (usually hunks) left to review.
+
#[derive(Clone, Default, Debug)]
+
pub struct Hunks {
+
    hunks: Vec<HunkDiff>,
+
}
+

+
impl Hunks {
+
    pub fn new(base: Diff) -> Self {
+
        let base_files = base.into_files();
+

+
        let mut queue = Self::default();
+
        for file in base_files {
+
            queue.add_file(file);
+
        }
+
        queue
+
    }
+

+
    /// Add a file to the queue.
+
    /// Mostly splits files into individual review items (eg. hunks) to review.
+
    fn add_file(&mut self, file: FileDiff) {
+
        let header = FileHeader::from(&file);
+

+
        match file {
+
            FileDiff::Moved(moved) => {
+
                self.add_item(HunkDiff::Moved { moved });
+
            }
+
            FileDiff::Copied(copied) => {
+
                self.add_item(HunkDiff::Copied {
+
                    copied: copied.clone(),
+
                });
+
            }
+
            FileDiff::Added(a) => {
+
                self.add_item(HunkDiff::Added {
+
                    path: a.path.clone(),
+
                    header: header.clone(),
+
                    new: a.new.clone(),
+
                    hunk: if let DiffContent::Plain {
+
                        hunks: Hunks(mut hs),
+
                        ..
+
                    } = a.diff.clone()
+
                    {
+
                        hs.pop()
+
                    } else {
+
                        None
+
                    },
+
                    _stats: a.diff.stats().cloned(),
+
                });
+
            }
+
            FileDiff::Deleted(d) => {
+
                self.add_item(HunkDiff::Deleted {
+
                    path: d.path.clone(),
+
                    header: header.clone(),
+
                    old: d.old.clone(),
+
                    hunk: if let DiffContent::Plain {
+
                        hunks: Hunks(mut hs),
+
                        ..
+
                    } = d.diff.clone()
+
                    {
+
                        hs.pop()
+
                    } else {
+
                        None
+
                    },
+
                    _stats: d.diff.stats().cloned(),
+
                });
+
            }
+
            FileDiff::Modified(m) => {
+
                if m.old.mode != m.new.mode {
+
                    self.add_item(HunkDiff::ModeChanged {
+
                        path: m.path.clone(),
+
                        header: header.clone(),
+
                        old: m.old.clone(),
+
                        new: m.new.clone(),
+
                    });
+
                }
+
                match m.diff.clone() {
+
                    DiffContent::Empty => {
+
                        // Likely a file mode change, which is handled above.
+
                    }
+
                    DiffContent::Binary => {
+
                        self.add_item(HunkDiff::Modified {
+
                            path: m.path.clone(),
+
                            header: header.clone(),
+
                            old: m.old.clone(),
+
                            new: m.new.clone(),
+
                            hunk: None,
+
                            _stats: m.diff.stats().cloned(),
+
                        });
+
                    }
+
                    DiffContent::Plain {
+
                        hunks: Hunks(hunks),
+
                        eof,
+
                        stats,
+
                    } => {
+
                        let base_hunks = hunks.clone();
+

+
                        for hunk in base_hunks {
+
                            self.add_item(HunkDiff::Modified {
+
                                path: m.path.clone(),
+
                                header: header.clone(),
+
                                old: m.old.clone(),
+
                                new: m.new.clone(),
+
                                hunk: Some(hunk),
+
                                _stats: Some(stats),
+
                            });
+
                        }
+
                        if let EofNewLine::OldMissing | EofNewLine::NewMissing = eof {
+
                            self.add_item(HunkDiff::EofChanged {
+
                                path: m.path.clone(),
+
                                header: header.clone(),
+
                                old: m.old.clone(),
+
                                new: m.new.clone(),
+
                                _eof: eof,
+
                            })
+
                        }
+
                    }
+
                }
+
            }
+
        }
+
    }
+

+
    fn add_item(&mut self, item: HunkDiff) {
+
        self.hunks.push(item);
+
    }
+
}
+

+
impl std::ops::Deref for Hunks {
+
    type Target = Vec<HunkDiff>;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.hunks
+
    }
+
}
+

+
impl std::ops::DerefMut for Hunks {
+
    fn deref_mut(&mut self) -> &mut Self::Target {
+
        &mut self.hunks
+
    }
+
}
+

+
pub struct DiffUtil<'a> {
+
    repo: &'a Repository,
+
}
+

+
impl<'a> DiffUtil<'a> {
+
    pub fn new(repo: &'a Repository) -> Self {
+
        Self { repo }
+
    }
+

+
    pub fn all_diffs(&self, revision: &Revision) -> anyhow::Result<Diff> {
+
        let repo = self.repo.raw();
+

+
        let base = repo.find_commit((*revision.base()).into())?.tree()?;
+
        let revision = {
+
            let commit = repo.find_commit(revision.head().into())?;
+
            commit.tree()?
+
        };
+

+
        let mut opts = git::raw::DiffOptions::new();
+
        opts.patience(true).minimal(true).context_lines(3_u32);
+

+
        let base_diff = self.diff(&base, &revision, repo, &mut opts)?;
+

+
        Ok(base_diff)
+
    }
+

+
    pub fn diff(
+
        &self,
+
        brain: &git::raw::Tree<'_>,
+
        tree: &git::raw::Tree<'_>,
+
        repo: &'a git::raw::Repository,
+
        opts: &mut git::raw::DiffOptions,
+
    ) -> Result<Diff, Error> {
+
        let mut find_opts = git::raw::DiffFindOptions::new();
+
        find_opts.exact_match_only(true);
+
        find_opts.all(true);
+
        find_opts.copies(false); // We don't support finding copies at the moment.
+

+
        let mut diff = repo.diff_tree_to_tree(Some(brain), Some(tree), Some(opts))?;
+
        diff.find_similar(Some(&mut find_opts))?;
+

+
        let diff = Diff::try_from(diff)?;
+

+
        Ok(diff)
+
    }
+
}
+

+
/// Builds a patch review interactively, across multiple files.
+
pub struct ReviewBuilder<'a> {
+
    /// Stored copy of repository.
+
    repo: &'a Repository,
+
}
+

+
impl<'a> ReviewBuilder<'a> {
+
    /// Create a new review builder.
+
    pub fn new(repo: &'a Repository) -> Self {
+
        Self { repo }
+
    }
+

+
    pub fn hunks(&self, revision: &Revision) -> anyhow::Result<Hunks> {
+
        let diff = DiffUtil::new(self.repo).all_diffs(revision)?;
+
        Ok(Hunks::new(diff))
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub struct ReviewComment {
+
    pub location: CodeLocation,
+
    pub body: String,
+
}
+

+
#[derive(thiserror::Error, Debug)]
+
pub enum Error {
+
    #[error(transparent)]
+
    Diff(#[from] unified_diff::Error),
+
    #[error(transparent)]
+
    Surf(#[from] radicle_surf::diff::git::error::Diff),
+
    #[error(transparent)]
+
    Io(#[from] io::Error),
+
    #[error(transparent)]
+
    Format(#[from] std::fmt::Error),
+
    #[error(transparent)]
+
    Git(#[from] git::raw::Error),
+
}
+

+
#[derive(Debug)]
+
pub struct CommentBuilder {
+
    commit: Oid,
+
    path: PathBuf,
+
    comments: Vec<ReviewComment>,
+
}
+

+
impl CommentBuilder {
+
    pub fn new(commit: Oid, path: PathBuf) -> Self {
+
        Self {
+
            commit,
+
            path,
+
            comments: Vec::new(),
+
        }
+
    }
+

+
    pub fn edit(mut self, hunk: &Hunk<Modification>) -> Result<Vec<ReviewComment>, Error> {
+
        let mut input = String::new();
+
        for line in hunk.to_unified_string()?.lines() {
+
            writeln!(&mut input, "> {line}")?;
+
        }
+

+
        let output = term::Editor::comment()
+
            .extension("diff")
+
            .initial(input)?
+
            .edit()?;
+

+
        if let Some(output) = output {
+
            let header = HunkHeader::try_from(hunk)?;
+
            self.add_hunk(header, &output);
+
        }
+
        Ok(self.comments())
+
    }
+

+
    pub fn add_hunk(&mut self, hunk: HunkHeader, input: &str) -> &mut Self {
+
        let lines = input.trim().lines().map(|l| l.trim());
+
        let (mut old_line, mut new_line) = (hunk.old_line_no as usize, hunk.new_line_no as usize);
+
        let (mut old_start, mut new_start) = (old_line, new_line);
+
        let mut comment = String::new();
+

+
        for line in lines {
+
            if line.starts_with('>') {
+
                if !comment.is_empty() {
+
                    self.add_comment(
+
                        &hunk,
+
                        &comment,
+
                        old_start..old_line - 1,
+
                        new_start..new_line - 1,
+
                    );
+

+
                    old_start = old_line - 1;
+
                    new_start = new_line - 1;
+

+
                    comment.clear();
+
                }
+
                match line.trim_start_matches('>').trim_start().chars().next() {
+
                    Some('-') => old_line += 1,
+
                    Some('+') => new_line += 1,
+
                    _ => {
+
                        old_line += 1;
+
                        new_line += 1;
+
                    }
+
                }
+
            } else {
+
                comment.push_str(line);
+
                comment.push('\n');
+
            }
+
        }
+
        if !comment.is_empty() {
+
            self.add_comment(
+
                &hunk,
+
                &comment,
+
                old_start..old_line - 1,
+
                new_start..new_line - 1,
+
            );
+
        }
+
        self
+
    }
+

+
    fn add_comment(
+
        &mut self,
+
        hunk: &HunkHeader,
+
        comment: &str,
+
        mut old_range: Range<usize>,
+
        mut new_range: Range<usize>,
+
    ) {
+
        // Empty lines between quoted text can generate empty comments
+
        // that should be filtered out.
+
        if comment.trim().is_empty() {
+
            return;
+
        }
+
        // Top-level comment, it should apply to the whole hunk.
+
        if old_range.is_empty() && new_range.is_empty() {
+
            old_range = hunk.old_line_no as usize..(hunk.old_line_no + hunk.old_size + 1) as usize;
+
            new_range = hunk.new_line_no as usize..(hunk.new_line_no + hunk.new_size + 1) as usize;
+
        }
+
        let old_range = old_range
+
            .is_empty()
+
            .not()
+
            .then_some(old_range)
+
            .map(|range| CodeRange::Lines { range });
+
        let new_range = (new_range)
+
            .is_empty()
+
            .not()
+
            .then_some(new_range)
+
            .map(|range| CodeRange::Lines { range });
+

+
        self.comments.push(ReviewComment {
+
            location: CodeLocation {
+
                commit: self.commit,
+
                path: self.path.clone(),
+
                old: old_range,
+
                new: new_range,
+
            },
+
            body: comment.trim().to_owned(),
+
        });
+
    }
+

+
    fn comments(self) -> Vec<ReviewComment> {
+
        self.comments
+
    }
+
}
+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+
    use std::str::FromStr;
+

+
    #[test]
+
    fn test_review_comments_basic() {
+
        let input = r#"
+
> @@ -2559,18 +2560,18 @@ where
+
>                  // Only consider onion addresses if configured.
+
>                  AddressType::Onion => self.config.onion.is_some(),
+
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
+
> -            })
+
> -            .take(wanted)
+
> -            .collect::<Vec<_>>(); // # -2564
+

+
Comment #1.
+

+
> +            });
+
>
+
> -        if available.len() < target {
+
> -            log::warn!( # -2567
+
> +        // Peers we are going to attempt connections to.
+
> +        let connect = available.take(wanted).collect::<Vec<_>>();
+

+
Comment #2.
+

+
> +        if connect.len() < wanted {
+
> +            log::debug!(
+
>                  target: "service",
+
> -                "Not enough available peers to connect to (available={}, target={target})",
+
> -                available.len()
+

+
Comment #3.
+

+
> +                "Not enough available peers to connect to (available={}, wanted={wanted})",
+

+
Comment #4.
+

+
> +                connect.len()
+
>              );
+
>          }
+
> -        for (id, ka) in available {
+
> +        for (id, ka) in connect {
+
>              self.connect(id, ka.addr.clone());
+
>          }
+
>     }
+

+
Comment #5.
+

+
"#;
+

+
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
+
        let path = PathBuf::from_str("main.rs").unwrap();
+
        let expected = &[
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
+
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
+
                },
+
                body: "Comment #1.".to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
+
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
+
                },
+
                body: "Comment #2.".to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2568..2571 }),
+
                    new: Some(CodeRange::Lines { range: 2567..2570 }),
+
                },
+
                body: "Comment #3.".to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: None,
+
                    new: Some(CodeRange::Lines { range: 2570..2571 }),
+
                },
+
                body: "Comment #4.".to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2571..2577 }),
+
                    new: Some(CodeRange::Lines { range: 2571..2578 }),
+
                },
+
                body: "Comment #5.".to_owned(),
+
            }),
+
        ];
+

+
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        builder.add_hunk(
+
            HunkHeader {
+
                old_line_no: 2559,
+
                old_size: 18,
+
                new_line_no: 2560,
+
                new_size: 18,
+
                text: vec![],
+
            },
+
            input,
+
        );
+
        let actual = builder.comments();
+

+
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
+

+
        for (left, right) in actual.iter().zip(expected) {
+
            assert_eq!(left, right);
+
        }
+
    }
+

+
    #[test]
+
    fn test_review_comments_multiline() {
+
        let input = r#"
+
> @@ -2559,9 +2560,7 @@ where
+
>                  // Only consider onion addresses if configured.
+
>                  AddressType::Onion => self.config.onion.is_some(),
+
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
+
> -            })
+
> -            .take(wanted)
+
> -            .collect::<Vec<_>>(); // # -2564
+

+
Blah blah blah blah blah blah blah.
+
Blah blah blah.
+

+
Blaah blaah blaah blaah blaah blaah blaah.
+
blaah blaah blaah.
+

+
Blaaah blaaah blaaah.
+

+
> +            });
+
>
+
> -        if available.len() < target {
+
> -            log::warn!( # -2567
+
> +        // Peers we are going to attempt connections to.
+
> +        let connect = available.take(wanted).collect::<Vec<_>>();
+

+
Woof woof.
+
Woof.
+
Woof.
+

+
Woof.
+

+
"#;
+

+
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
+
        let path = PathBuf::from_str("main.rs").unwrap();
+
        let expected = &[
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
+
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
+
                },
+
                body: r#"
+
Blah blah blah blah blah blah blah.
+
Blah blah blah.
+

+
Blaah blaah blaah blaah blaah blaah blaah.
+
blaah blaah blaah.
+

+
Blaaah blaaah blaaah.
+
"#
+
                .trim()
+
                .to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
+
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
+
                },
+
                body: r#"
+
Woof woof.
+
Woof.
+
Woof.
+

+
Woof.
+
"#
+
                .trim()
+
                .to_owned(),
+
            }),
+
        ];
+

+
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        builder.add_hunk(
+
            HunkHeader {
+
                old_line_no: 2559,
+
                old_size: 9,
+
                new_line_no: 2560,
+
                new_size: 7,
+
                text: vec![],
+
            },
+
            input,
+
        );
+
        let actual = builder.comments();
+

+
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
+

+
        for (left, right) in actual.iter().zip(expected) {
+
            assert_eq!(left, right);
+
        }
+
    }
+

+
    #[test]
+
    fn test_review_comments_before() {
+
        let input = r#"
+
This is a top-level comment.
+

+
> @@ -2559,9 +2560,7 @@ where
+
>                  // Only consider onion addresses if configured.
+
>                  AddressType::Onion => self.config.onion.is_some(),
+
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
+
> -            })
+
> -            .take(wanted)
+
> -            .collect::<Vec<_>>(); // # -2564
+
> +            });
+
>
+
> -        if available.len() < target {
+
> -            log::warn!( # -2567
+
> +        // Peers we are going to attempt connections to.
+
> +        let connect = available.take(wanted).collect::<Vec<_>>();
+
"#;
+

+
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
+
        let path = PathBuf::from_str("main.rs").unwrap();
+
        let expected = &[(ReviewComment {
+
            location: CodeLocation {
+
                commit,
+
                path: path.clone(),
+
                old: Some(CodeRange::Lines { range: 2559..2569 }),
+
                new: Some(CodeRange::Lines { range: 2560..2568 }),
+
            },
+
            body: "This is a top-level comment.".to_owned(),
+
        })];
+

+
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        builder.add_hunk(
+
            HunkHeader {
+
                old_line_no: 2559,
+
                old_size: 9,
+
                new_line_no: 2560,
+
                new_size: 7,
+
                text: vec![],
+
            },
+
            input,
+
        );
+
        let actual = builder.comments();
+

+
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
+

+
        for (left, right) in actual.iter().zip(expected) {
+
            assert_eq!(left, right);
+
        }
+
    }
+

+
    #[test]
+
    fn test_review_comments_split_hunk() {
+
        let input = r#"
+
> @@ -2559,6 +2560,4 @@ where
+
>                  // Only consider onion addresses if configured.
+
>                  AddressType::Onion => self.config.onion.is_some(),
+
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
+
> -            })
+
> -            .take(wanted)
+

+
> -            .collect::<Vec<_>>();
+
> +            });
+

+
Comment on a split hunk.
+
"#;
+

+
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
+
        let path = PathBuf::from_str("main.rs").unwrap();
+
        let expected = &[(ReviewComment {
+
            location: CodeLocation {
+
                commit,
+
                path: path.clone(),
+
                old: Some(CodeRange::Lines { range: 2564..2565 }),
+
                new: Some(CodeRange::Lines { range: 2563..2564 }),
+
            },
+
            body: "Comment on a split hunk.".to_owned(),
+
        })];
+

+
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        builder.add_hunk(
+
            HunkHeader {
+
                old_line_no: 2559,
+
                old_size: 6,
+
                new_line_no: 2560,
+
                new_size: 4,
+
                text: vec![],
+
            },
+
            input,
+
        );
+
        let actual = builder.comments();
+

+
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
+

+
        for (left, right) in actual.iter().zip(expected) {
+
            assert_eq!(left, right);
+
        }
+
    }
+
}
deleted bin/commands.rs
@@ -1,8 +0,0 @@
-
#[path = "commands/help.rs"]
-
pub mod tui_help;
-
#[path = "commands/inbox.rs"]
-
pub mod tui_inbox;
-
#[path = "commands/issue.rs"]
-
pub mod tui_issue;
-
#[path = "commands/patch.rs"]
-
pub mod tui_patch;
deleted bin/commands/help.rs
@@ -1,100 +0,0 @@
-
use std::ffi::OsString;
-

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

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

-
use super::*;
-

-
pub const HELP: Help = Help {
-
    name: "help",
-
    description: "Print help",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: "Usage: rad-tui help [--help]",
-
};
-

-
const COMMANDS: &[Help] = &[tui_help::HELP];
-

-
#[derive(Default)]
-
pub struct Options {}
-

-
impl Args for Options {
-
    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<()> {
-
    println!(
-
        "{} {}",
-
        term::format::secondary("Usage:").bold(),
-
        term::format::tertiary("rad-tui [COMMAND] [OPTIONS]"),
-
    );
-

-
    if let Err(e) = ctx.profile() {
-
        term::blank();
-
        match e.downcast_ref() {
-
            Some(Error::WithHint { err, hint }) => {
-
                term::print(term::format::yellow(err));
-
                term::print(term::format::yellow(hint));
-
            }
-
            Some(e) => {
-
                term::error(e);
-
            }
-
            None => {
-
                term::error(e);
-
            }
-
        }
-
        term::blank();
-
    }
-

-
    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::tertiary(format!("{:-16}", help.name)),
-
            term::format::default(help.description)
-
        );
-
    }
-

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

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

-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
-

-
use radicle::node::notifications::NotificationId;
-
use radicle::storage::{HasRepoId, ReadRepository};
-

-
use radicle_cli::terminal::{Args, Error, Help};
-

-
use crate::terminal;
-
use crate::ui::items::notification::filter::SortBy;
-

-
use self::list::{InboxOperation, RepositoryMode};
-

-
pub const HELP: Help = Help {
-
    name: "inbox",
-
    description: "Terminal interfaces for notifications",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

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

-
List options
-

-
    --sort-by <field>       Sort by `id` or `timestamp` (default: timestamp)
-
    --reverse, -r           Reverse the list
-

-
    --json                  Return JSON on stderr instead of calling `rad`
-

-
Other options
-

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

-
#[derive(Debug, PartialEq)]
-
pub struct Options {
-
    op: Operation,
-
}
-

-
#[derive(Debug, PartialEq)]
-
pub enum Operation {
-
    List { opts: ListOptions },
-
    Other { args: Vec<OsString> },
-
    Unknown { args: Vec<OsString> },
-
}
-

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

-
#[derive(Debug, Default, Clone, PartialEq)]
-
pub struct ListOptions {
-
    mode: RepositoryMode,
-
    sort_by: 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.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 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') => {
-
                    help = true;
-
                    // Only enable forwarding if it was not already disabled explicitly
-
                    forward = match forward {
-
                        Some(false) => Some(false),
-
                        _ => Some(true),
-
                    };
-
                }
-

-
                Long("reverse") | Short('r') => {
-
                    reverse = Some(true);
-
                }
-
                Long("sort-by") => {
-
                    use radicle_cli::terminal;
-

-
                    let val = parser.value()?;
-
                    match terminal::args::string(&val).as_str() {
-
                        "timestamp" => field = Some("timestamp"),
-
                        "id" => field = Some("id"),
-
                        other => anyhow::bail!("unknown sorting field '{other}'"),
-
                    }
-
                }
-

-
                Long("repo") if repository_mode.is_none() => {
-
                    use radicle_cli::terminal;
-

-
                    let val = parser.value()?;
-
                    let repo = terminal::args::rid(&val)?;
-
                    repository_mode = Some(RepositoryMode::ByRepo((repo, None)));
-
                }
-
                Long("all") | Short('a') if repository_mode.is_none() => {
-
                    repository_mode = Some(RepositoryMode::All);
-
                }
-

-
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
-
                    "list" => op = OperationName::List,
-
                    _ => {
-
                        op = OperationName::Unknown;
-
                        // Only enable forwarding if it was not already disabled explicitly
-
                        forward = match forward {
-
                            Some(false) => Some(false),
-
                            _ => Some(true),
-
                        };
-
                    }
-
                },
-
                _ => {
-
                    if op == OperationName::List {
-
                        return Err(anyhow!(arg.unexpected()));
-
                    }
-
                }
-
            }
-
        }
-

-
        // 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 = repository_mode.unwrap_or_default();
-
        list_opts.sort_by = if let Some(field) = field {
-
            SortBy {
-
                field,
-
                reverse: reverse.unwrap_or(false),
-
            }
-
        } else {
-
            SortBy::default()
-
        };
-

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

-
        Ok((Options { op }, vec![]))
-
    }
-
}
-

-
#[tokio::main]
-
pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) -> anyhow::Result<()> {
-
    use radicle::storage::ReadStorage;
-

-
    let (_, rid) = radicle::rad::cwd()
-
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
-

-
    match options.op {
-
        Operation::List { opts } => {
-
            #[derive(Default)]
-
            struct PreviousState {
-
                notif_id: Option<NotificationId>,
-
                search: Option<String>,
-
            }
-

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

-
            let mut state = PreviousState::default();
-
            loop {
-
                let profile = ctx.profile()?;
-
                let repository = profile.storage.repository(rid)?;
-

-
                let context = list::Context {
-
                    profile,
-
                    project: repository.identity_doc()?.project()?,
-
                    rid: repository.rid(),
-
                    mode: opts.mode.clone(),
-
                    search: state.search.clone(),
-
                    sort_by: opts.sort_by,
-
                    _notif_id: state.notif_id,
-
                };
-

-
                let app = list::Tui::new(context);
-
                let selection = app.run().await?;
-

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

-
                    log::info!("Exiting inbox listing app..");
-
                    eprint!("{selection}");
-
                } else if let Some(selection) = selection {
-
                    if let Some(operation) = selection.operation.clone() {
-
                        match operation {
-
                            InboxOperation::Show { id, search } => {
-
                                state = PreviousState {
-
                                    notif_id: Some(id),
-
                                    search: Some(search),
-
                                };
-
                                terminal::run_rad(
-
                                    Some("inbox"),
-
                                    &["show".into(), id.to_string().into()],
-
                                )?;
-
                            }
-
                            InboxOperation::Clear { id, search } => {
-
                                state = PreviousState {
-
                                    notif_id: Some(id),
-
                                    search: Some(search),
-
                                };
-
                                terminal::run_rad(
-
                                    Some("inbox"),
-
                                    &["clear".into(), id.to_string().into()],
-
                                )?;
-
                            }
-
                        }
-
                    }
-
                } else {
-
                    break;
-
                }
-
            }
-
        }
-
        Operation::Other { args } => {
-
            terminal::run_rad(Some("inbox"), &args)?;
-
        }
-
        Operation::Unknown { .. } => {
-
            anyhow::bail!("unknown operation provided");
-
        }
-
    }
-

-
    Ok(())
-
}
-

-
#[cfg(test)]
-
mod cli {
-
    use radicle_cli::terminal::args::Error;
-
    use radicle_cli::terminal::Args;
-

-
    use super::{ListOptions, Operation, Options};
-

-
    #[test]
-
    fn empty_operation_should_default_to_list_and_not_be_forwarded(
-
    ) -> Result<(), Box<dyn std::error::Error>> {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec![];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn empty_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["--help".into()];
-
        let expected_op = Operation::Other { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn empty_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let args = vec!["--help".into(), "--no-forward".into()];
-

-
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
-
        assert!(matches!(actual, Error::Help));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn empty_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec!["--no-forward".into()];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec!["list".into()];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_should_not_be_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec!["list".into(), "--no-forward".into()];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["list".into(), "--help".into()];
-
        let expected_op = Operation::Other { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let args = vec!["list".into(), "--help".into(), "--no-forward".into()];
-
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
-

-
        assert!(matches!(actual, Error::Help));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_with_help_should_not_be_forwarded_reversed(
-
    ) -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["list".into(), "--no-forward".into(), "--help".into()];
-
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
-

-
        assert!(matches!(actual, Error::Help));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn unknown_operation_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["operation".into()];
-
        let expected_op = Operation::Other { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn unknown_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["operation".into(), "--no-forward".into()];
-
        let expected_op = Operation::Unknown { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-
}
deleted bin/commands/inbox/list.rs
@@ -1,856 +0,0 @@
-
use std::collections::hash_map::Entry;
-
use std::collections::HashMap;
-
use std::str::FromStr;
-
use std::sync::{Arc, Mutex};
-
use std::vec;
-

-
use anyhow::Result;
-

-
use ratatui::widgets::Clear;
-
use serde::Serialize;
-

-
use radicle::node::notifications::NotificationId;
-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::prelude::*;
-
use ratatui::text::Span;
-
use ratatui::{Frame, Viewport};
-

-
use radicle::identity::Project;
-
use radicle::prelude::RepoId;
-
use radicle::storage::ReadStorage;
-
use radicle::Profile;
-

-
use radicle_tui as tui;
-

-
use tui::event::Key;
-
use tui::store;
-
use tui::task::{Process, Task};
-
use tui::ui;
-
use tui::ui::layout::Spacing;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::{
-
    Borders, Column, ContainerState, TableState, TextEditState, TextViewState, Window,
-
};
-
use tui::ui::{BufferedValue, Show, Ui};
-
use tui::{Channel, Exit};
-

-
use crate::settings;
-
use crate::ui::items::filter::Filter;
-
use crate::ui::items::notification::filter::{NotificationFilter, SortBy};
-
use crate::ui::items::notification::{Notification, NotificationKind};
-

-
#[derive(Clone, Default, Debug, PartialEq, Eq)]
-
pub enum RepositoryMode {
-
    #[default]
-
    Contextual,
-
    All,
-
    ByRepo((RepoId, Option<String>)),
-
}
-

-
/// The selected issue operation returned by the operation
-
/// selection widget.
-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub enum InboxOperation {
-
    Show { id: NotificationId, search: String },
-
    Clear { id: NotificationId, search: String },
-
}
-

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

-
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`:      Cancel
-
`q`:        Quit
-

-
# Specific keybindings
-

-
`enter`:    Show notification
-
`r`:        Reload notifications
-
`c`:        Clear notification
-
`/`:        Search
-
`?`:        Show help
-

-
# Searching
-

-
Examples:   state=unseen kind=cob bugfix
-
            kind=(cob:xyz.radicle.issue or cob:xyz.radicle.issue)
-
            state=unseen author=(did:key:... or did:key:...)"#;
-

-
#[derive(Clone, Debug)]
-
pub struct Context {
-
    pub profile: Profile,
-
    pub project: Project,
-
    pub rid: RepoId,
-
    pub mode: RepositoryMode,
-
    pub sort_by: SortBy,
-
    pub _notif_id: Option<NotificationId>,
-
    pub search: Option<String>,
-
}
-

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

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

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let viewport = Viewport::Inline(20);
-
        let channel = Channel::default();
-
        let state = App::try_from(&self.context)?;
-

-
        tui::im(
-
            state,
-
            viewport,
-
            channel,
-
            vec![Loader::new(self.context.clone())],
-
        )
-
        .await
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Change {
-
    Page {
-
        page: Page,
-
    },
-
    MainGroup {
-
        state: ContainerState,
-
    },
-
    Patches {
-
        state: TableState,
-
    },
-
    Search {
-
        search: BufferedValue<TextEditState>,
-
    },
-
    Help {
-
        state: TextViewState,
-
    },
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Initialize,
-
    Changed(Change),
-
    ShowSearch,
-
    HideSearch { apply: bool },
-
    Reload,
-
    Loaded(Vec<Notification>),
-
    Exit { operation: Option<InboxOperation> },
-
    Quit,
-
}
-

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

-
#[derive(Clone, Debug)]
-
pub struct AppState {
-
    page: Page,
-
    main_group: ContainerState,
-
    patches: TableState,
-
    search: BufferedValue<TextEditState>,
-
    show_search: bool,
-
    help: TextViewState,
-
    filter: NotificationFilter,
-
    loading: bool,
-
    initialized: bool,
-
    theme: Theme,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct App {
-
    context: Arc<Mutex<Context>>,
-
    notifications: Arc<Mutex<Vec<Notification>>>,
-
    state: AppState,
-
}
-

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

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

-
        let search = context.search.as_ref().map(|s| s.trim().to_string());
-
        let (search, filter) = match search {
-
            Some(search) => (
-
                search.clone(),
-
                NotificationFilter::from_str(search.trim()).unwrap_or(NotificationFilter::Invalid),
-
            ),
-
            None => {
-
                let filter = NotificationFilter::default();
-
                (filter.to_string().trim().to_string(), filter)
-
            }
-
        };
-

-
        Ok(App {
-
            context: Arc::new(Mutex::new(context.clone())),
-
            notifications: Arc::new(Mutex::new(vec![])),
-
            state: AppState {
-
                page: Page::Main,
-
                main_group: ContainerState::new(3, Some(0)),
-
                patches: TableState::new(Some(0)),
-
                search: BufferedValue::new(TextEditState {
-
                    text: search.to_string(),
-
                    cursor: search.chars().count(),
-
                }),
-
                show_search: false,
-
                help: TextViewState::new(Position::default()),
-
                filter,
-
                loading: false,
-
                initialized: false,
-
                theme,
-
            },
-
        })
-
    }
-
}
-

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

-
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
-
        match message {
-
            Message::Initialize => {
-
                self.state.loading = true;
-
                self.state.initialized = true;
-
                None
-
            }
-
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => Some(Exit {
-
                value: Some(Selection {
-
                    operation,
-
                    args: vec![],
-
                }),
-
            }),
-
            Message::ShowSearch => {
-
                self.state.main_group = ContainerState::new(3, None);
-
                self.state.show_search = true;
-
                None
-
            }
-
            Message::HideSearch { apply } => {
-
                self.state.main_group = ContainerState::new(3, Some(0));
-
                self.state.show_search = false;
-

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

-
                self.state.filter = NotificationFilter::from_str(&self.state.search.read().text)
-
                    .unwrap_or(NotificationFilter::Invalid);
-

-
                None
-
            }
-
            Message::Reload => {
-
                self.state.loading = true;
-
                None
-
            }
-
            Message::Loaded(notifications) => {
-
                self.apply_notifications(notifications);
-
                self.apply_sorting();
-
                self.state.loading = false;
-
                None
-
            }
-
            Message::Changed(changed) => match changed {
-
                Change::Page { page } => {
-
                    self.state.page = page;
-
                    None
-
                }
-
                Change::MainGroup { state } => {
-
                    self.state.main_group = state;
-
                    None
-
                }
-
                Change::Patches { state } => {
-
                    self.state.patches = state;
-
                    None
-
                }
-
                Change::Search { search } => {
-
                    self.state.search = search;
-
                    self.state.filter =
-
                        NotificationFilter::from_str(&self.state.search.read().text)
-
                            .unwrap_or(NotificationFilter::Invalid);
-
                    self.state.patches.select_first();
-
                    None
-
                }
-
                Change::Help { state } => {
-
                    self.state.help = state;
-
                    None
-
                }
-
            },
-
        }
-
    }
-
}
-

-
impl Show<Message> for App {
-
    fn show(&self, ctx: &ui::Context<Message>, frame: &mut Frame) -> Result<()> {
-
        Window::default().show(ctx, self.state.theme.clone(), |ui| {
-
            // Initialize
-
            if !self.state.initialized {
-
                ui.send_message(Message::Initialize);
-
            }
-

-
            match self.state.page {
-
                Page::Main => {
-
                    let show_search = self.state.show_search;
-
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
-

-
                    ui.container(
-
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
-
                        &mut page_focus,
-
                        |ui| {
-
                            let mut group_focus = self.state.main_group.focus();
-

-
                            let group = ui.container(
-
                                ui::Layout::Expandable3 { left_only: true },
-
                                &mut group_focus,
-
                                |ui| {
-
                                    self.show_browser(frame, ui);
-
                                },
-
                            );
-
                            if group.response.changed {
-
                                ui.send_message(Message::Changed(Change::MainGroup {
-
                                    state: ContainerState::new(3, group_focus),
-
                                }));
-
                            }
-

-
                            if show_search {
-
                                self.show_browser_search(frame, ui);
-
                            } else if let Some(0) = group_focus {
-
                                self.show_browser_footer(frame, ui);
-
                            }
-
                        },
-
                    );
-

-
                    if !show_search && ui.has_input(|key| key == Key::Char('?')) {
-
                        ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
-
                    }
-
                }
-

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

-
                    ui.container(layout, &mut Some(1), |ui| {
-
                        self.show_help_text(frame, ui);
-
                        self.show_help_context(frame, ui);
-

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

-
                    if ui.has_input(|key| key == Key::Char('?')) {
-
                        ui.send_message(Message::Changed(Change::Page { page: Page::Main }));
-
                    }
-
                }
-
            }
-
            if ui.has_input(|key| key == Key::Char('q')) {
-
                ui.send_message(Message::Quit);
-
            }
-
            if ui.has_input(|key| key == Key::Ctrl('c')) {
-
                ui.send_message(Message::Quit);
-
            }
-
        });
-

-
        Ok(())
-
    }
-
}
-

-
impl App {
-
    pub fn show_browser(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        let context = self.context.lock().unwrap();
-
        let notifs = self.notifications.lock().unwrap();
-
        let notifs = notifs
-
            .iter()
-
            .filter(|notif| self.state.filter.matches(notif))
-
            .cloned()
-
            .collect::<Vec<_>>();
-
        let mut selected = self.state.patches.selected();
-

-
        let header = [
-
            Column::new(Span::raw(" ● ").bold(), Constraint::Length(3)),
-
            Column::new(Span::raw("ID").bold(), Constraint::Length(8)).hide_medium(),
-
            Column::new(Span::raw("Summary").bold(), Constraint::Fill(1)),
-
            Column::new(Span::raw("Repository").bold(), Constraint::Length(16))
-
                .skip(context.mode != RepositoryMode::All),
-
            Column::new(Span::raw("OID").bold(), Constraint::Length(8)).hide_medium(),
-
            Column::new(Span::raw("Kind").bold(), Constraint::Length(20)).hide_small(),
-
            Column::new(Span::raw("Change").bold(), Constraint::Length(8)).hide_small(),
-
            Column::new(Span::raw("Author").bold(), Constraint::Length(16)).hide_medium(),
-
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)),
-
        ];
-

-
        ui.layout(
-
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
-
            Some(1),
-
            |ui| {
-
                ui.column_bar(frame, header.to_vec(), Spacing::from(1), Some(Borders::Top));
-

-
                let table = ui.table(
-
                    frame,
-
                    &mut selected,
-
                    &notifs,
-
                    header.to_vec(),
-
                    Some("".into()),
-
                    Spacing::from(1),
-
                    Some(Borders::BottomSides),
-
                );
-
                if table.changed {
-
                    ui.send_message(Message::Changed(Change::Patches {
-
                        state: TableState::new(selected),
-
                    }));
-
                }
-

-
                if self.state.loading {
-
                    self.show_loading_popup(frame, ui);
-
                }
-

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

-
        if ui.has_input(|key| key == Key::Char('r')) {
-
            ui.send_message(Message::Reload);
-
        }
-

-
        if let Some(notification) = selected.and_then(|s| notifs.get(s)) {
-
            if ui.has_input(|key| key == Key::Enter) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(InboxOperation::Show {
-
                        id: notification.id,
-
                        search: self.state.search.read().text,
-
                    }),
-
                });
-
            }
-
            if ui.has_input(|key| key == Key::Char('c')) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(InboxOperation::Clear {
-
                        id: notification.id,
-
                        search: self.state.search.read().text,
-
                    }),
-
                });
-
            }
-
        }
-
    }
-

-
    fn show_browser_footer(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.layout(Layout::vertical([3, 1]), None, |ui| {
-
            self.show_browser_context(frame, ui);
-
            self.show_browser_shortcuts(frame, ui);
-
        });
-
    }
-

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

-
        let text_edit = ui.text_edit_singleline(
-
            frame,
-
            &mut search_text,
-
            &mut search_cursor,
-
            Some("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::Changed(Change::Search { search }));
-
        }
-

-
        if ui.has_input(|key| key == Key::Esc) {
-
            ui.send_message(Message::HideSearch { apply: false });
-
        }
-
        if ui.has_input(|key| key == Key::Enter) {
-
            ui.send_message(Message::HideSearch { apply: true });
-
        }
-
    }
-

-
    fn show_browser_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        let context = {
-
            let notifs = self.notifications.lock().unwrap();
-
            let search = self.state.search.read().text;
-
            let total_count = notifs.len();
-
            let filtered_count = notifs
-
                .iter()
-
                .filter(|patch| self.state.filter.matches(patch))
-
                .collect::<Vec<_>>()
-
                .len();
-

-
            let filtered_counts = format!(" {filtered_count}/{total_count} ");
-
            let seen_counts = notifs
-
                .iter()
-
                .fold((0, 0), |counts, notif| match notif.seen {
-
                    true => (counts.0 + 1, counts.1),
-
                    false => (counts.0, counts.1 + 1),
-
                });
-

-
            if self.state.filter.is_default() {
-
                let seen = format!(" {} ", seen_counts.0);
-
                let unseen = format!(" {} ", seen_counts.1);
-
                [
-
                    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)
-
                            .cyan()
-
                            .dim(),
-
                        Constraint::Fill(1),
-
                    ),
-
                    Column::new(
-
                        Span::raw("●")
-
                            .style(ui.theme().bar_on_black_style)
-
                            .gray()
-
                            .dim()
-
                            .bold(),
-
                        Constraint::Length(1),
-
                    ),
-
                    Column::new(
-
                        Span::raw(seen.clone())
-
                            .style(ui.theme().bar_on_black_style)
-
                            .dim(),
-
                        Constraint::Length(seen.chars().count() as u16),
-
                    ),
-
                    Column::new(
-
                        Span::raw("●")
-
                            .style(ui.theme().bar_on_black_style)
-
                            .cyan()
-
                            .dim()
-
                            .bold(),
-
                        Constraint::Length(1),
-
                    ),
-
                    Column::new(
-
                        Span::raw(unseen.clone())
-
                            .style(ui.theme().bar_on_black_style)
-
                            .dim(),
-
                        Constraint::Length(unseen.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)
-
                            .cyan(),
-
                        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()
-
            }
-
        };
-

-
        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
-
    }
-

-
    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.shortcuts(
-
            frame,
-
            &[
-
                ("enter", "show"),
-
                ("r", "reload"),
-
                ("c", "clear"),
-
                ("/", "search"),
-
                ("?", "help"),
-
            ],
-
            '∙',
-
            Alignment::Left,
-
        );
-
    }
-

-
    fn show_loading_popup(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.popup(Layout::vertical([Constraint::Min(1)]), |ui| {
-
            ui.layout(
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).margin(1),
-
                None,
-
                |ui| {
-
                    ui.label(frame, "");
-
                    ui.layout(
-
                        Layout::horizontal([Constraint::Min(1), Constraint::Length(11)]),
-
                        None,
-
                        |ui| {
-
                            ui.label(frame, "");
-
                            ui.layout(Layout::vertical([Constraint::Min(1)]), None, |ui| {
-
                                frame.render_widget(Clear, ui.area());
-
                                ui.column_bar(
-
                                    frame,
-
                                    [Column::new(
-
                                        Span::raw(" Loading ").magenta().rapid_blink(),
-
                                        Constraint::Fill(1),
-
                                    )]
-
                                    .to_vec(),
-
                                    Spacing::from(0),
-
                                    Some(Borders::All),
-
                                );
-
                            });
-
                        },
-
                    );
-
                },
-
            );
-
            ui.centered_text_view(frame, "Loading".slow_blink().yellow(), None);
-
        });
-
    }
-

-
    fn show_help_text(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.column_bar(
-
            frame,
-
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
-
            Spacing::from(0),
-
            Some(Borders::Top),
-
        );
-

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

-
    fn show_help_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.column_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(),
-
            Spacing::from(0),
-
            Some(Borders::None),
-
        );
-
    }
-
}
-

-
impl App {
-
    fn apply_notifications(&mut self, notifications: Vec<Notification>) {
-
        let mut items = self.notifications.lock().unwrap();
-
        *items = notifications;
-
    }
-

-
    fn apply_sorting(&mut self) {
-
        let mut items = self.notifications.lock().unwrap();
-
        let context = self.context.lock().unwrap();
-
        // Apply sorting
-
        match context.sort_by.field {
-
            "timestamp" => items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
-
            "id" => items.sort_by(|a, b| a.id.cmp(&b.id)),
-
            _ => {}
-
        }
-
        if context.sort_by.reverse {
-
            items.reverse();
-
        }
-

-
        // Set project name
-
        let mode = match context.mode {
-
            RepositoryMode::ByRepo((rid, _)) => {
-
                let name = context.project.name().to_string();
-
                RepositoryMode::ByRepo((rid, Some(name)))
-
            }
-
            _ => context.mode.clone(),
-
        };
-

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

-
#[derive(Clone, Debug)]
-
pub struct Loader {
-
    context: Context,
-
}
-

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

-
#[derive(Debug)]
-
pub struct NotificationLoader {
-
    context: Context,
-
}
-

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

-
impl Task for NotificationLoader {
-
    type Return = Message;
-

-
    fn run(&self) -> anyhow::Result<Vec<Self::Return>> {
-
        let profile = self.context.profile.clone();
-
        let notifs = profile.notifications_mut()?;
-

-
        let notifications = match self.context.mode {
-
            RepositoryMode::All => {
-
                // Store all repos the notifs arised from, such that
-
                // they can be referenced when loading issues and patches
-
                let repos = notifs
-
                    .all()?
-
                    .filter_map(|notif| notif.ok())
-
                    .filter_map(|notif| {
-
                        profile
-
                            .storage
-
                            .repository(notif.repo)
-
                            .ok()
-
                            .map(|repo| (notif.repo, repo))
-
                    })
-
                    .collect::<HashMap<_, _>>();
-

-
                // Only retrieve issues and patches once per repository
-
                let (mut issues, mut patches) = (HashMap::new(), HashMap::new());
-
                notifs
-
                    .all()?
-
                    .filter_map(|notif| notif.ok())
-
                    .map(|notif| match repos.get(&notif.repo) {
-
                        Some(repo) => {
-
                            let project = repo.project()?;
-
                            let (issues, patches) = {
-
                                (
-
                                    match issues.entry(repo.id) {
-
                                        Entry::Occupied(e) => e.into_mut(),
-
                                        Entry::Vacant(e) => e.insert(profile.issues(repo)?),
-
                                    },
-
                                    match patches.entry(repo.id) {
-
                                        Entry::Occupied(e) => e.into_mut(),
-
                                        Entry::Vacant(e) => e.insert(profile.patches(repo)?),
-
                                    },
-
                                )
-
                            };
-

-
                            match NotificationKind::new(repo, issues, patches, &notif)? {
-
                                Some(kind) => Notification::new(&profile, &project, &notif, kind),
-
                                _ => Ok(None),
-
                            }
-
                        }
-
                        _ => Ok(None),
-
                    })
-
                    .filter_map(|notif| notif.ok())
-
                    .flatten()
-
                    .collect::<Vec<_>>()
-
            }
-
            RepositoryMode::Contextual => {
-
                let repo = profile.storage.repository(self.context.rid)?;
-
                let project = repo.project()?;
-
                let issues = profile.issues(&repo)?;
-
                let patches = profile.patches(&repo)?;
-
                let by_repo = notifs.by_repo(&repo.id, "timestamp")?;
-

-
                by_repo
-
                    .filter_map(|notif| notif.ok())
-
                    .map(
-
                        |notif| match NotificationKind::new(&repo, &issues, &patches, &notif)? {
-
                            Some(kind) => Notification::new(&profile, &project, &notif, kind),
-
                            _ => Ok(None),
-
                        },
-
                    )
-
                    .filter_map(|notif| notif.ok())
-
                    .flatten()
-
                    .collect::<Vec<_>>()
-
            }
-
            RepositoryMode::ByRepo((rid, _)) => {
-
                let repo = profile.storage.repository(rid)?;
-
                let project = repo.project()?;
-
                let issues = profile.issues(&repo)?;
-
                let patches = profile.patches(&repo)?;
-
                let by_repo = notifs.by_repo(&repo.id, "timestamp")?;
-

-
                by_repo
-
                    .filter_map(|notif| notif.ok())
-
                    .map(
-
                        |notif| match NotificationKind::new(&repo, &issues, &patches, &notif)? {
-
                            Some(kind) => Notification::new(&profile, &project, &notif, kind),
-
                            _ => Ok(None),
-
                        },
-
                    )
-
                    .filter_map(|notif| notif.ok())
-
                    .flatten()
-
                    .collect::<Vec<_>>()
-
            }
-
        };
-

-
        Ok(vec![Message::Loaded(notifications)])
-
    }
-
}
-

-
impl Process<Message> for Loader {
-
    async fn process(&mut self, message: Message) -> anyhow::Result<Vec<Message>> {
-
        match message {
-
            Message::Initialize | Message::Reload => {
-
                let loader = NotificationLoader::new(self.context.clone());
-
                let messages = tokio::spawn(async move { loader.run() }).await.unwrap()?;
-
                Ok(messages)
-
            }
-
            _ => Ok(vec![]),
-
        }
-
    }
-
}
deleted bin/commands/issue.rs
@@ -1,551 +0,0 @@
-
#[path = "issue/list.rs"]
-
mod list;
-

-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
-

-
use radicle::cob::thread::CommentId;
-
use radicle::identity::RepoId;
-
use radicle::issue::{IssueId, State};
-
use radicle::prelude::Did;
-
use radicle::{issue, storage, Profile};
-

-
use radicle_cli as cli;
-

-
use cli::terminal::patch::Message;
-
use cli::terminal::Context;
-
use cli::terminal::{Args, Error, Help};
-

-
use crate::commands::tui_issue::list::IssueOperation;
-
use crate::terminal;
-
use crate::ui::items::filter::DidFilter;
-
use crate::ui::items::issue::filter::IssueFilter;
-

-
pub const HELP: Help = Help {
-
    name: "issue",
-
    description: "Terminal interfaces for issues",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad-tui issue list [<option>...]
-

-
List options
-

-
    --json              Return JSON on stderr instead of calling `rad`
-

-
Other options
-

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

-
#[derive(Debug, PartialEq)]
-
pub struct Options {
-
    op: Operation,
-
    repo: Option<RepoId>,
-
}
-

-
#[derive(Debug, PartialEq)]
-
pub enum Operation {
-
    List { opts: ListOptions },
-
    Other { args: Vec<OsString> },
-
    Unknown { args: Vec<OsString> },
-
}
-

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

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub struct ListFilter {
-
    state: Option<State>,
-
    assigned: bool,
-
    assignees: Vec<Did>,
-
}
-

-
impl Default for ListFilter {
-
    fn default() -> Self {
-
        Self {
-
            state: Some(State::default()),
-
            assigned: false,
-
            assignees: vec![],
-
        }
-
    }
-
}
-

-
impl ListFilter {
-
    pub fn with_state(mut self, state: Option<State>) -> Self {
-
        self.state = state;
-
        self
-
    }
-

-
    pub fn with_assgined(mut self, assigned: bool) -> Self {
-
        self.assigned = assigned;
-
        self
-
    }
-

-
    pub fn with_assginee(mut self, assignee: Did) -> Self {
-
        self.assignees.push(assignee);
-
        self
-
    }
-
}
-

-
#[allow(clippy::from_over_into)]
-
impl Into<IssueFilter> for (Did, ListFilter) {
-
    fn into(self) -> IssueFilter {
-
        let (me, mut filter) = self;
-
        let mut and = filter
-
            .state
-
            .map(|s| vec![IssueFilter::State(s)])
-
            .unwrap_or(vec![]);
-

-
        let mut assignees = filter.assigned.then_some(vec![me]).unwrap_or_default();
-
        assignees.append(&mut filter.assignees);
-

-
        if assignees.len() == 1 {
-
            and.push(IssueFilter::Assignee(DidFilter::Single(
-
                *assignees.first().unwrap(),
-
            )));
-
        } else if assignees.len() > 1 {
-
            and.push(IssueFilter::Assignee(DidFilter::Or(assignees)));
-
        }
-

-
        IssueFilter::And(and)
-
    }
-
}
-

-
#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub struct ListOptions {
-
    filter: ListFilter,
-
    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.clone());
-
        let mut op = OperationName::List;
-
        let mut repo = None;
-
        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') => {
-
                    help = true;
-
                    // Only enable forwarding if it was not already disabled explicitly
-
                    forward = match forward {
-
                        Some(false) => Some(false),
-
                        _ => Some(true),
-
                    };
-
                }
-
                Long("all") if op == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_state(None);
-
                }
-
                Long("open") if op == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Open));
-
                }
-
                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 == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Closed {
-
                        reason: issue::CloseReason::Other,
-
                    }));
-
                }
-
                Long("assigned") if op == OperationName::List => {
-
                    if let Ok(val) = parser.value() {
-
                        list_opts.filter = list_opts
-
                            .filter
-
                            .with_assginee(cli::terminal::args::did(&val)?);
-
                    } else {
-
                        list_opts.filter = list_opts.filter.with_assgined(true);
-
                    }
-
                }
-

-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let rid = cli::terminal::args::rid(&val)?;
-

-
                    repo = Some(rid);
-
                }
-

-
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
-
                    "list" => op = OperationName::List,
-
                    _ => {
-
                        op = OperationName::Unknown;
-
                        // Only enable forwarding if it was not already disabled explicitly
-
                        forward = match forward {
-
                            Some(false) => Some(false),
-
                            _ => Some(true),
-
                        };
-
                    }
-
                },
-
                _ => {
-
                    if op == OperationName::List {
-
                        return Err(anyhow!(arg.unexpected()));
-
                    }
-
                }
-
            }
-
        }
-

-
        // 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 },
-
            },
-
            OperationName::Unknown if !forward => Operation::Unknown { args },
-
            _ => Operation::Other { args },
-
        };
-

-
        Ok((Options { op, repo }, vec![]))
-
    }
-
}
-

-
#[tokio::main]
-
pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
-
    use radicle::storage::ReadStorage;
-

-
    let (_, rid) = radicle::rad::cwd()
-
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
-

-
    match options.op {
-
        Operation::List { opts } => {
-
            if let Err(err) = crate::log::enable() {
-
                println!("{err}");
-
            }
-
            log::info!("Starting issue listing app in project {rid}..");
-

-
            #[derive(Default)]
-
            struct PreviousState {
-
                issue_id: Option<IssueId>,
-
                comment_id: Option<CommentId>,
-
                search: Option<String>,
-
            }
-

-
            // Store issue and comment selection across app runs in order to
-
            // preselect them when re-running the app.
-
            let mut state = PreviousState::default();
-

-
            loop {
-
                let profile = ctx.profile()?;
-
                let me = profile.did();
-
                let rid = options.repo.unwrap_or(rid);
-
                let repository = profile.storage.repository(rid)?;
-

-
                let context = list::Context {
-
                    profile,
-
                    repository,
-
                    filter: (me, opts.filter.clone()).into(),
-
                    search: state.search.clone(),
-
                    issue: state.issue_id,
-
                    comment: state.comment_id,
-
                };
-

-
                let tui = list::Tui::new(context);
-
                let selection = tui.run().await?;
-

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

-
                    log::info!("Exiting issue listing app..");
-
                    eprint!("{selection}");
-
                } else if let Some(selection) = selection {
-
                    if let Some(operation) = selection.operation.clone() {
-
                        match operation {
-
                            IssueOperation::Show { args } => {
-
                                state = PreviousState {
-
                                    issue_id: Some(args.id()),
-
                                    comment_id: None,
-
                                    search: Some(args.search()),
-
                                };
-
                                terminal::run_rad(
-
                                    Some("issue"),
-
                                    &["show".into(), args.id().to_string().into()],
-
                                )?;
-
                            }
-
                            IssueOperation::Edit { args, comment_id } => {
-
                                state = PreviousState {
-
                                    issue_id: Some(args.id()),
-
                                    comment_id,
-
                                    search: Some(args.search()),
-
                                };
-
                                match comment_id {
-
                                    Some(comment_id) => {
-
                                        terminal::run_rad(
-
                                            Some("issue"),
-
                                            &[
-
                                                "comment".into(),
-
                                                args.id().to_string().into(),
-
                                                "--edit".into(),
-
                                                comment_id.to_string().into(),
-
                                            ],
-
                                        )?;
-
                                    }
-
                                    _ => {
-
                                        terminal::run_rad(
-
                                            Some("issue"),
-
                                            &["edit".into(), args.id().to_string().into()],
-
                                        )?;
-
                                    }
-
                                }
-
                            }
-
                            IssueOperation::Solve { args } => {
-
                                state = PreviousState {
-
                                    issue_id: Some(args.id()),
-
                                    comment_id: None,
-
                                    search: Some(args.search()),
-
                                };
-
                                terminal::run_rad(
-
                                    Some("issue"),
-
                                    &[
-
                                        "state".into(),
-
                                        args.id().to_string().into(),
-
                                        "--solved".into(),
-
                                    ],
-
                                )?;
-
                            }
-
                            IssueOperation::Close { args } => {
-
                                state = PreviousState {
-
                                    issue_id: Some(args.id()),
-
                                    comment_id: None,
-
                                    search: Some(args.search()),
-
                                };
-
                                terminal::run_rad(
-
                                    Some("issue"),
-
                                    &[
-
                                        "state".into(),
-
                                        args.id().to_string().into(),
-
                                        "--closed".into(),
-
                                    ],
-
                                )?;
-
                            }
-
                            IssueOperation::Reopen { args } => {
-
                                state = PreviousState {
-
                                    issue_id: Some(args.id()),
-
                                    comment_id: None,
-
                                    search: Some(args.search()),
-
                                };
-
                                terminal::run_rad(
-
                                    Some("issue"),
-
                                    &[
-
                                        "state".into(),
-
                                        args.id().to_string().into(),
-
                                        "--open".into(),
-
                                    ],
-
                                )?;
-
                            }
-
                            IssueOperation::Comment { args, reply_to } => {
-
                                let comment_id = comment(
-
                                    &tui.context().profile,
-
                                    &tui.context().repository,
-
                                    args.id(),
-
                                    Message::Edit,
-
                                    reply_to,
-
                                )?;
-
                                state = PreviousState {
-
                                    issue_id: Some(args.id()),
-
                                    comment_id: Some(comment_id),
-
                                    search: Some(args.search()),
-
                                };
-
                            }
-
                        }
-
                    }
-
                } else {
-
                    break;
-
                }
-
            }
-
        }
-
        Operation::Other { args } => {
-
            terminal::run_rad(Some("issue"), &args)?;
-
        }
-
        Operation::Unknown { .. } => {
-
            anyhow::bail!("unknown operation provided");
-
        }
-
    }
-

-
    Ok(())
-
}
-

-
fn comment(
-
    profile: &Profile,
-
    repo: &storage::git::Repository,
-
    issue_id: IssueId,
-
    message: Message,
-
    reply_to: Option<CommentId>,
-
) -> Result<CommentId, anyhow::Error> {
-
    let mut issues = profile.issues_mut(repo)?;
-
    let signer = cli::terminal::signer(profile)?;
-
    let mut issue = issues.get_mut(&issue_id)?;
-
    let (root_comment_id, _) = issue.root();
-
    let body = terminal::prompt_comment(message, issue.thread(), reply_to, None)?;
-
    let comment_id = issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
-

-
    Ok(comment_id)
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use radicle_cli::terminal::args::Error;
-
    use radicle_cli::terminal::Args;
-

-
    use super::{ListOptions, Operation, Options};
-

-
    #[test]
-
    fn empty_operation_should_default_to_list_and_not_be_forwarded(
-
    ) -> Result<(), Box<dyn std::error::Error>> {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec![];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn empty_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["--help".into()];
-
        let expected_op = Operation::Other { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn empty_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let args = vec!["--help".into(), "--no-forward".into()];
-

-
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
-
        assert!(matches!(actual, Error::Help));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn empty_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec!["--no-forward".into()];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec!["list".into()];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_should_not_be_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec!["list".into(), "--no-forward".into()];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["list".into(), "--help".into()];
-
        let expected_op = Operation::Other { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let args = vec!["list".into(), "--help".into(), "--no-forward".into()];
-
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
-

-
        assert!(matches!(actual, Error::Help));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_with_help_should_not_be_forwarded_reversed(
-
    ) -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["list".into(), "--no-forward".into(), "--help".into()];
-
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
-

-
        assert!(matches!(actual, Error::Help));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn unknown_operation_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["operation".into()];
-
        let expected_op = Operation::Other { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn unknown_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["operation".into(), "--no-forward".into()];
-
        let expected_op = Operation::Unknown { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-
}
deleted bin/commands/issue/list.rs
@@ -1,1229 +0,0 @@
-
use std::collections::{HashMap, HashSet};
-
use std::str::FromStr;
-
use std::sync::{Arc, Mutex};
-

-
use radicle::cob::ObjectId;
-
use serde::Serialize;
-

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

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

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

-
use radicle_tui as tui;
-

-
use tui::event::Key;
-
use tui::store;
-
use tui::task::EmptyProcessors;
-
use tui::ui;
-
use tui::ui::layout::Spacing;
-
use tui::ui::span;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::{
-
    Borders, Column, ContainerState, TableState, TextEditState, TextViewState, TreeState, Window,
-
};
-
use tui::ui::{BufferedValue, Show, ToRow, Ui};
-
use tui::{Channel, Exit};
-

-
use crate::cob::issue;
-
use crate::settings;
-
use crate::ui::format;
-
use crate::ui::items::filter::Filter;
-
use crate::ui::items::issue::filter::IssueFilter;
-
use crate::ui::items::issue::Issue;
-
use crate::ui::items::HasId;
-

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

-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub struct OperationArguments {
-
    id: IssueId,
-
    search: String,
-
}
-

-
impl OperationArguments {
-
    pub fn id(&self) -> ObjectId {
-
        self.id
-
    }
-

-
    pub fn search(&self) -> String {
-
        self.search.clone()
-
    }
-
}
-

-
impl TryFrom<(&Vec<Issue>, &AppState)> for OperationArguments {
-
    type Error = anyhow::Error;
-

-
    fn try_from(value: (&Vec<Issue>, &AppState)) -> Result<Self> {
-
        let (issues, state) = value;
-
        let selected = state.browser.selected();
-
        let id = selected
-
            .and_then(|s| issues.get(s))
-
            .ok_or(anyhow!("No issue selected"))?
-
            .id;
-
        let search = state.browser.search.read().text;
-

-
        Ok(Self { id, search })
-
    }
-
}
-

-
/// The selected issue operation returned by the operation
-
/// selection widget.
-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub enum IssueOperation {
-
    Edit {
-
        args: OperationArguments,
-
        comment_id: Option<CommentId>,
-
    },
-
    Show {
-
        args: OperationArguments,
-
    },
-
    Close {
-
        args: OperationArguments,
-
    },
-
    Solve {
-
        args: OperationArguments,
-
    },
-
    Reopen {
-
        args: OperationArguments,
-
    },
-
    Comment {
-
        args: OperationArguments,
-
        reply_to: Option<CommentId>,
-
    },
-
}
-

-
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
-
`Tab`:      focus next section
-
`BackTab`:  focus previous section
-
`Esc`:      Cancel
-
`q`:        Quit
-

-
# Specific keybindings
-

-
`/`:        Search
-
`Enter`:    Show issue
-
`e`:        Edit issue
-
`s`:        Solve issue
-
`l`:        Close issue
-
`o`:        Re-open issue
-
`c`:        Reply to comment
-
`p`:        Toggle issue preview
-
`?`:        Show help"#;
-

-
pub struct Context {
-
    pub profile: Profile,
-
    pub repository: Repository,
-
    pub filter: IssueFilter,
-
    pub search: Option<String>,
-
    pub issue: Option<IssueId>,
-
    pub comment: Option<CommentId>,
-
}
-

-
pub(crate) struct Tui {
-
    pub(crate) context: Context,
-
}
-

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

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let viewport = Viewport::Inline(20);
-
        let channel = Channel::default();
-
        let state = App::try_from(&self.context)?;
-

-
        tui::im(state, viewport, channel, EmptyProcessors::new()).await
-
    }
-

-
    pub fn context(&self) -> &Context {
-
        &self.context
-
    }
-
}
-

-
mod args {
-
    use super::*;
-
    use crate::ui::items::CommentItem;
-

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

-
    #[derive(Clone, Default, Debug, Eq, PartialEq)]
-
    pub(crate) enum Section {
-
        #[default]
-
        Browser,
-
        Issue,
-
        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::Issue),
-
                2 => Ok(Section::Comment),
-
                _ => bail!("Unknown section index: {}", value),
-
            }
-
        }
-
    }
-

-
    #[derive(Clone, Debug)]
-
    pub(crate) struct Browser {
-
        pub(crate) issues: TableState,
-
        pub(crate) search: BufferedValue<TextEditState>,
-
        pub(crate) show_search: bool,
-
    }
-

-
    impl Browser {
-
        pub fn selected(&self) -> Option<usize> {
-
            self.issues.selected()
-
        }
-
    }
-

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

-
    impl Preview {
-
        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().find(|item| item.id == comment_id))
-
            })
-
        }
-

-
        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
-
        }
-
    }
-

-
    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,
-
            );
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Change {
-
    Page { page: args::Page },
-
    Section { state: ContainerState },
-
    Issue { state: TableState },
-
    Comment { state: TreeState<String> },
-
    CommentBody { state: TextViewState },
-
    ShowSearch { state: bool, apply: bool },
-
    ShowPreview { state: bool },
-
    Search { state: BufferedValue<TextEditState> },
-
    Help { state: TextViewState },
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Changed(Change),
-
    Exit { operation: Option<IssueOperation> },
-
    Quit,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct AppState {
-
    page: args::Page,
-
    sections: ContainerState,
-
    browser: args::Browser,
-
    preview: args::Preview,
-
    help: TextViewState,
-
    filter: IssueFilter,
-
    theme: Theme,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct App {
-
    issues: Arc<Mutex<Vec<Issue>>>,
-
    state: AppState,
-
}
-

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

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

-
        let issues = issue::all(&context.profile, &context.repository)?;
-
        let search = context.search.as_ref().map(|s| s.trim().to_string());
-
        let (search, filter) = match search {
-
            Some(search) => (
-
                search.clone(),
-
                IssueFilter::from_str(search.trim()).unwrap_or(IssueFilter::Invalid),
-
            ),
-
            None => {
-
                let filter = context.filter.clone();
-
                (filter.to_string().trim().to_string(), filter)
-
            }
-
        };
-

-
        // Convert into UI items
-
        let mut issues: Vec<_> = issues
-
            .into_iter()
-
            .flat_map(|issue| Issue::new(&context.profile, issue).ok())
-
            .collect();
-

-
        issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
-

-
        // Pre-select comments per issue. If a comment to pre-select is given,
-
        // find identifier path needed for selection. Select root comment
-
        // otherwise.
-
        let selected_comments: HashMap<_, _> = issues
-
            .iter()
-
            .map(|issue| {
-
                let comment_ids = match context.comment {
-
                    Some(comment_id) if issue.has_comment(&comment_id) => {
-
                        issue.path_to_comment(&comment_id).unwrap_or_default()
-
                    }
-
                    _ => issue
-
                        .root_comments()
-
                        .first()
-
                        .map(|c| vec![c.id])
-
                        .unwrap_or_default(),
-
                };
-
                (issue.id, comment_ids)
-
            })
-
            .collect();
-

-
        let browser = args::Browser {
-
            issues: TableState::new(Some(
-
                context
-
                    .issue
-
                    .and_then(|id| {
-
                        issues
-
                            .iter()
-
                            .filter(|item| filter.matches(item))
-
                            .position(|item| item.id() == id)
-
                    })
-
                    .unwrap_or(0),
-
            )),
-
            search: BufferedValue::new(TextEditState {
-
                text: search.clone(),
-
                cursor: search.chars().count(),
-
            }),
-
            show_search: false,
-
        };
-

-
        let preview = args::Preview {
-
            show: true,
-
            issue: browser
-
                .selected()
-
                .and_then(|s| {
-
                    issues
-
                        .iter()
-
                        .filter(|item| filter.matches(item))
-
                        .collect::<Vec<_>>()
-
                        .get(s)
-
                        .cloned()
-
                })
-
                .cloned(),
-
            selected_comments,
-
            comment: TextViewState::new(Position::default()),
-
        };
-

-
        let section = if context.comment.is_some() {
-
            args::Section::Issue
-
        } else {
-
            args::Section::Browser
-
        };
-

-
        Ok(Self {
-
            issues: Arc::new(Mutex::new(issues)),
-
            state: AppState {
-
                page: args::Page::Main,
-
                sections: ContainerState::new(3, Some(section as usize)),
-
                browser,
-
                preview,
-
                filter,
-
                help: TextViewState::new(Position::default()),
-
                theme,
-
            },
-
        })
-
    }
-
}
-

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

-
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
-
        match message {
-
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => Some(Exit {
-
                value: Some(Selection {
-
                    operation,
-
                    args: vec![],
-
                }),
-
            }),
-
            Message::Changed(changed) => match changed {
-
                Change::Page { page } => {
-
                    self.state.page = page;
-
                    None
-
                }
-
                Change::Section { state } => {
-
                    self.state.sections = state;
-
                    None
-
                }
-
                Change::Issue { state } => {
-
                    let issues = self.issues.lock().unwrap();
-
                    let issues = issues
-
                        .clone()
-
                        .into_iter()
-
                        .filter(|issue| self.state.filter.matches(issue))
-
                        .collect::<Vec<_>>();
-

-
                    self.state.browser.issues = state;
-
                    self.state.preview.issue = self
-
                        .state
-
                        .browser
-
                        .selected()
-
                        .and_then(|s| issues.get(s).cloned());
-
                    self.state.preview.comment = TextViewState::new(Position::default());
-
                    None
-
                }
-
                Change::ShowSearch { state, apply } => {
-
                    if state {
-
                        self.state.sections = ContainerState::new(self.state.sections.len(), None);
-
                        self.state.browser.show_search = true;
-
                    } else {
-
                        let issues = self.issues.lock().unwrap();
-
                        let issues = issues
-
                            .clone()
-
                            .into_iter()
-
                            .filter(|issue| self.state.filter.matches(issue))
-
                            .collect::<Vec<_>>();
-

-
                        self.state.preview.issue = self
-
                            .state
-
                            .browser
-
                            .selected()
-
                            .and_then(|s| issues.get(s).cloned());
-
                        self.state.sections =
-
                            ContainerState::new(self.state.sections.len(), Some(0));
-
                        self.state.browser.show_search = false;
-

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

-
                        self.state.filter =
-
                            IssueFilter::from_str(&self.state.browser.search.read().text)
-
                                .unwrap_or_default();
-
                    }
-
                    None
-
                }
-
                Change::ShowPreview { state } => {
-
                    self.state.preview.show = state;
-
                    self.state.sections = ContainerState::new(if state { 3 } else { 1 }, Some(0));
-
                    None
-
                }
-
                Change::Search { state } => {
-
                    let issues = self.issues.lock().unwrap();
-
                    let issues = issues
-
                        .clone()
-
                        .into_iter()
-
                        .filter(|issue| self.state.filter.matches(issue))
-
                        .collect::<Vec<_>>();
-

-
                    self.state.browser.search = state.clone();
-
                    self.state.filter =
-
                        IssueFilter::from_str(&state.read().text).unwrap_or_default();
-
                    self.state.browser.issues.select_first();
-

-
                    self.state.preview.issue = self
-
                        .state
-
                        .browser
-
                        .selected()
-
                        .and_then(|s| issues.get(s).cloned());
-
                    None
-
                }
-
                Change::Comment { state } => {
-
                    if let Some(item) = &self.state.preview.issue {
-
                        self.state.preview.selected_comments.insert(
-
                            item.id,
-
                            state
-
                                .internal
-
                                .selected()
-
                                .iter()
-
                                .map(|s| CommentId::from_str(s).unwrap())
-
                                .collect(),
-
                        );
-
                    }
-
                    self.state.preview.comment = TextViewState::new(Position::default());
-
                    None
-
                }
-
                Change::CommentBody { state } => {
-
                    self.state.preview.comment = state;
-
                    None
-
                }
-
                Change::Help { state } => {
-
                    self.state.help = state;
-
                    None
-
                }
-
            },
-
        }
-
    }
-
}
-

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

-
                    ui.layout(
-
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
-
                        page_focus,
-
                        |ui| {
-
                            let (mut focus, count) =
-
                                { (self.state.sections.focus(), self.state.sections.len()) };
-

-
                            let group = ui.container(
-
                                ui::Layout::Expandable3 {
-
                                    left_only: !self.state.preview.show,
-
                                },
-
                                &mut focus,
-
                                |ui| {
-
                                    self.show_browser(frame, ui);
-
                                    self.show_issue(frame, ui);
-
                                    self.show_comment(frame, ui);
-
                                },
-
                            );
-

-
                            if group.response.changed {
-
                                ui.send_message(Message::Changed(Change::Section {
-
                                    state: ContainerState::new(count, focus),
-
                                }));
-
                            }
-

-
                            ui.layout(
-
                                Layout::vertical(match show_search {
-
                                    true => [2, 0],
-
                                    false => [1, 1],
-
                                }),
-
                                Some(0),
-
                                |ui| {
-
                                    use args::Section;
-
                                    if let Some(section) = focus {
-
                                        match Section::try_from(section).unwrap_or_default() {
-
                                            Section::Browser => {
-
                                                self.show_browser_context(frame, ui);
-
                                                self.show_browser_shortcuts(frame, ui);
-
                                            }
-
                                            Section::Issue => {
-
                                                self.show_issue_context(frame, ui);
-
                                                self.show_issue_shortcuts(frame, ui);
-
                                            }
-
                                            Section::Comment => {
-
                                                self.show_comment_context(frame, ui);
-
                                                self.show_comment_shortcuts(frame, ui);
-
                                            }
-
                                        }
-
                                    } else if show_search {
-
                                        self.show_browser_search(frame, ui);
-
                                    }
-
                                },
-
                            );
-
                        },
-
                    );
-

-
                    if !show_search {
-
                        if ui.has_input(|key| key == Key::Char('p')) {
-
                            ui.send_message(Message::Changed(Change::ShowPreview {
-
                                state: !self.state.preview.show,
-
                            }));
-
                        }
-
                        if ui.has_input(|key| key == Key::Char('?')) {
-
                            ui.send_message(Message::Changed(Change::Page {
-
                                page: args::Page::Help,
-
                            }));
-
                        }
-
                    }
-
                }
-
                args::Page::Help => {
-
                    let layout = Layout::vertical([
-
                        Constraint::Length(3),
-
                        Constraint::Fill(1),
-
                        Constraint::Length(1),
-
                        Constraint::Length(1),
-
                    ]);
-

-
                    ui.container(layout, &mut Some(1), |ui| {
-
                        self.show_help_text(frame, ui);
-
                        self.show_help_context(frame, ui);
-

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

-
                    if ui.has_input(|key| key == Key::Char('?')) {
-
                        ui.send_message(Message::Changed(Change::Page {
-
                            page: args::Page::Main,
-
                        }));
-
                    }
-
                }
-
            }
-

-
            if ui.has_input(|key| key == Key::Char('q')) {
-
                ui.send_message(Message::Quit);
-
            }
-
            if ui.has_input(|key| key == Key::Ctrl('c')) {
-
                ui.send_message(Message::Quit);
-
            }
-
        });
-

-
        Ok(())
-
    }
-
}
-

-
impl App {
-
    pub fn show_browser(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        let issues = self.issues.lock().unwrap();
-
        let issues = issues
-
            .iter()
-
            .filter(|patch| self.state.filter.matches(patch))
-
            .cloned()
-
            .collect::<Vec<_>>();
-
        let browser = &self.state.browser;
-
        let preview = &self.state.preview;
-
        let mut selected = browser.issues.selected();
-

-
        let 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(),
-
        ];
-

-
        ui.layout(
-
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
-
            Some(1),
-
            |ui| {
-
                ui.column_bar(frame, header.to_vec(), Spacing::from(1), Some(Borders::Top));
-

-
                let table = ui.table(
-
                    frame,
-
                    &mut selected,
-
                    &issues,
-
                    header.to_vec(),
-
                    None,
-
                    Spacing::from(1),
-
                    Some(Borders::BottomSides),
-
                );
-
                if table.changed {
-
                    ui.send_message(Message::Changed(Change::Issue {
-
                        state: TableState::new(selected),
-
                    }));
-
                }
-
            },
-
        );
-

-
        if ui.has_input(|key| key == Key::Char('/')) {
-
            ui.send_message(Message::Changed(Change::ShowSearch {
-
                state: true,
-
                apply: false,
-
            }));
-
        }
-

-
        if let Ok(args) = OperationArguments::try_from((&issues, &self.state)) {
-
            if ui.has_input(|key| key == Key::Enter) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Show { args: args.clone() }),
-
                });
-
            }
-

-
            if ui.has_input(|key| key == Key::Char('e')) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Edit {
-
                        args: args.clone(),
-
                        comment_id: preview.selected_comment().map(|c| c.id),
-
                    }),
-
                });
-
            }
-

-
            if ui.has_input(|key| key == Key::Char('s')) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Solve { args: args.clone() }),
-
                });
-
            }
-

-
            if ui.has_input(|key| key == Key::Char('l')) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Close { args: args.clone() }),
-
                });
-
            }
-

-
            if ui.has_input(|key| key == Key::Char('o')) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Reopen { args }),
-
                });
-
            }
-
        }
-
    }
-

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

-
        let text_edit = ui.text_edit_singleline(
-
            frame,
-
            &mut search_text,
-
            &mut search_cursor,
-
            Some("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::Changed(Change::Search { state: search }));
-
        }
-

-
        if ui.has_input(|key| key == Key::Esc) {
-
            ui.send_message(Message::Changed(Change::ShowSearch {
-
                state: false,
-
                apply: false,
-
            }));
-
        }
-
        if ui.has_input(|key| key == Key::Enter) {
-
            ui.send_message(Message::Changed(Change::ShowSearch {
-
                state: false,
-
                apply: true,
-
            }));
-
        }
-
    }
-

-
    fn show_browser_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        use radicle::issue::{CloseReason, State};
-

-
        let context = {
-
            let issues = self.issues.lock().unwrap();
-
            let filter = &self.state.filter;
-
            let filtered = issues
-
                .iter()
-
                .filter(|issue| filter.matches(issue))
-
                .collect::<Vec<_>>();
-

-
            let browser = &self.state.browser;
-
            let search = browser.search.read().text;
-

-
            let mut open = 0;
-
            let mut other = 0;
-
            let mut solved = 0;
-
            for issue in &filtered {
-
                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 filtered_counts = format!(" {}/{} ", filtered.len(), issues.len());
-
            if !self.state.filter.has_state() {
-
                [
-
                    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)
-
                            .cyan()
-
                            .dim(),
-
                        Constraint::Fill(1),
-
                    ),
-
                    Column::new(
-
                        Span::raw(" ● ")
-
                            .into_right_aligned_line()
-
                            .style(ui.theme().bar_on_black_style)
-
                            .green()
-
                            .dim(),
-
                        Constraint::Length(3),
-
                    ),
-
                    Column::new(
-
                        Span::from(open.to_string())
-
                            .style(ui.theme().bar_on_black_style)
-
                            .into_right_aligned_line(),
-
                        Constraint::Length(open.to_string().chars().count() as u16),
-
                    ),
-
                    Column::new(
-
                        Span::raw(" ● ")
-
                            .style(ui.theme().bar_on_black_style)
-
                            .into_right_aligned_line()
-
                            .red()
-
                            .dim(),
-
                        Constraint::Length(3),
-
                    ),
-
                    Column::new(
-
                        Span::from(closed.to_string())
-
                            .style(ui.theme().bar_on_black_style)
-
                            .into_right_aligned_line(),
-
                        Constraint::Length(closed.to_string().chars().count() as u16),
-
                    ),
-
                    Column::new(
-
                        Span::from(" ")
-
                            .style(ui.theme().bar_on_black_style)
-
                            .into_right_aligned_line(),
-
                        Constraint::Length(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()
-
            } else {
-
                [
-
                    Column::new(
-
                        Span::raw(" Search ".to_string()).cyan().dim().reversed(),
-
                        Constraint::Length(8),
-
                    ),
-
                    Column::new(
-
                        Span::from(" ")
-
                            .style(ui.theme().bar_on_black_style)
-
                            .into_right_aligned_line(),
-
                        Constraint::Length(1),
-
                    ),
-
                    Column::new(
-
                        Span::raw(search.to_string())
-
                            .into_left_aligned_line()
-
                            .style(ui.theme().bar_on_black_style)
-
                            .cyan()
-
                            .dim(),
-
                        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()
-
            }
-
        };
-

-
        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
-
    }
-

-
    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        use radicle::issue::State;
-

-
        let issues = self.issues.lock().unwrap();
-
        let issues = issues
-
            .iter()
-
            .filter(|issue| self.state.filter.matches(issue))
-
            .collect::<Vec<_>>();
-

-
        let mut shortcuts = vec![("/", "search"), ("enter", "show"), ("e", "edit")];
-
        if let Some(issue) = self.state.browser.selected().and_then(|i| issues.get(i)) {
-
            let actions = match issue.state {
-
                State::Open => vec![("s", "solve"), ("l", "close")],
-
                State::Closed { .. } => vec![("o", "re-open")],
-
            };
-
            shortcuts.extend_from_slice(&actions);
-
        }
-

-
        let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];
-

-
        ui.layout(
-
            Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]),
-
            None,
-
            |ui| {
-
                ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left);
-
                ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right);
-
            },
-
        );
-
    }
-

-
    pub fn show_issue(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        #[derive(Clone)]
-
        struct Property<'a>(Span<'a>, Text<'a>);
-

-
        impl<'a> ToRow<3> for Property<'a> {
-
            fn to_row(&self) -> [ratatui::widgets::Cell<'_>; 3] {
-
                ["".into(), self.0.clone().into(), self.1.clone().into()]
-
            }
-
        }
-

-
        let issues = self.issues.lock().unwrap();
-
        let issues = issues
-
            .iter()
-
            .filter(|issue| self.state.filter.matches(issue))
-
            .cloned()
-
            .collect::<Vec<_>>();
-
        let issue = self.state.browser.selected().and_then(|i| issues.get(i));
-
        let properties = issue
-
            .map(|issue| {
-
                use radicle::issue;
-

-
                let author: Text<'_> = match &issue.author.alias {
-
                    Some(alias) => {
-
                        if issue.author.you {
-
                            Line::from(
-
                                [
-
                                    span::alias(alias.as_ref()),
-
                                    Span::raw(" "),
-
                                    span::alias("(you)").dim().italic(),
-
                                ]
-
                                .to_vec(),
-
                            )
-
                            .into()
-
                        } else {
-
                            Line::from(
-
                                [
-
                                    span::alias(alias.as_ref()),
-
                                    Span::raw(" "),
-
                                    span::alias(&format!(
-
                                        "({})",
-
                                        issue.author.human_nid.clone().unwrap_or_default()
-
                                    ))
-
                                    .dim()
-
                                    .italic(),
-
                                ]
-
                                .to_vec(),
-
                            )
-
                            .into()
-
                        }
-
                    }
-
                    None => match &issue.author.human_nid {
-
                        Some(nid) => span::alias(nid).dim().into(),
-
                        None => span::blank().into(),
-
                    },
-
                };
-

-
                let status = match issue.state {
-
                    issue::State::Open => Text::from("open").green(),
-
                    issue::State::Closed { reason } => match reason {
-
                        issue::CloseReason::Solved => Line::from(
-
                            [
-
                                Span::from("closed").red(),
-
                                Span::raw(" "),
-
                                Span::from("(solved)").red().italic().dim(),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .into(),
-
                        issue::CloseReason::Other => Text::from("closed").red(),
-
                    },
-
                };
-

-
                vec![
-
                    Property(Span::from("Title"), Text::from(issue.title.clone()).bold()),
-
                    Property(Span::from("Issue"), Text::from(issue.id.to_string()).cyan()),
-
                    Property(Span::from("Author"), author.magenta()),
-
                    Property(
-
                        Span::from("Labels"),
-
                        Text::from(format::labels(&issue.labels)).blue(),
-
                    ),
-
                    Property(Span::from("Status"), status),
-
                ]
-
            })
-
            .unwrap_or_default();
-

-
        let preview = &self.state.preview;
-
        let comment = preview.selected_comment();
-
        let root = preview.root_comments();
-
        let mut opened = Some(preview.opened_comments());
-
        let mut selected = Some(preview.selected_comment_ids());
-

-
        ui.layout(
-
            Layout::vertical([Constraint::Length(7), Constraint::Fill(1)]),
-
            Some(1),
-
            |ui| {
-
                ui.table(
-
                    frame,
-
                    &mut None,
-
                    &properties,
-
                    vec![
-
                        Column::new("", Constraint::Length(1)),
-
                        Column::new("", Constraint::Length(12)),
-
                        Column::new("", Constraint::Fill(1)),
-
                    ],
-
                    None,
-
                    Spacing::from(0),
-
                    Some(Borders::Top),
-
                );
-
                let comments = ui.tree(
-
                    frame,
-
                    &root,
-
                    &mut opened,
-
                    &mut selected,
-
                    Some(Borders::BottomSides),
-
                );
-
                if comments.changed {
-
                    let mut state = tui_tree_widget::TreeState::default();
-
                    if let Some(opened) = opened {
-
                        for open in opened {
-
                            state.open(open);
-
                        }
-
                    }
-
                    if let Some(selected) = selected {
-
                        state.select(selected);
-
                    }
-

-
                    ui.send_message(Message::Changed(Change::Comment {
-
                        state: TreeState { internal: state },
-
                    }));
-
                }
-

-
                if let Ok(args) = OperationArguments::try_from((&issues, &self.state)) {
-
                    if ui.has_input(|key| key == Key::Char('c')) {
-
                        ui.send_message(Message::Exit {
-
                            operation: Some(IssueOperation::Comment {
-
                                args: args.clone(),
-
                                reply_to: comment.map(|c| c.id),
-
                            }),
-
                        });
-
                    }
-

-
                    if ui.has_input(|key| key == Key::Char('e')) {
-
                        ui.send_message(Message::Exit {
-
                            operation: Some(IssueOperation::Edit {
-
                                args,
-
                                comment_id: comment.map(|c| c.id),
-
                            }),
-
                        });
-
                    }
-
                }
-
            },
-
        );
-
    }
-

-
    pub fn show_issue_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.column_bar(
-
            frame,
-
            [
-
                Column::new(
-
                    Span::raw(" Comment ".to_string()).cyan().dim().reversed(),
-
                    Constraint::Length(9),
-
                ),
-
                Column::new(
-
                    Span::raw(" ".to_string())
-
                        .into_left_aligned_line()
-
                        .style(ui.theme().bar_on_black_style),
-
                    Constraint::Fill(1),
-
                ),
-
            ]
-
            .to_vec(),
-
            Spacing::from(0),
-
            Some(Borders::None),
-
        );
-
    }
-

-
    pub fn show_issue_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        let shortcuts = vec![("e", "edit"), ("c", "reply")];
-
        let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];
-

-
        ui.layout(
-
            Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]),
-
            None,
-
            |ui| {
-
                ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left);
-
                ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right);
-
            },
-
        );
-
    }
-

-
    pub fn show_comment(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        let (text, reactions, mut cursor) = {
-
            let comment = self.state.preview.selected_comment();
-
            let body: String = comment
-
                .map(|comment| comment.body.clone())
-
                .unwrap_or_default();
-
            let reactions = comment.and_then(|comment| {
-
                let reactions = comment.accumulated_reactions();
-
                if !reactions.is_empty() {
-
                    let reactions = reactions.iter().fold(String::new(), |all, (r, acc)| {
-
                        if *acc > 1_usize {
-
                            [all, format!("{r}{acc} ")].concat()
-
                        } else {
-
                            [all, format!("{r} ")].concat()
-
                        }
-
                    });
-
                    Some(reactions)
-
                } else {
-
                    None
-
                }
-
            });
-

-
            (body, reactions, self.state.preview.comment.clone().cursor())
-
        };
-
        let comment = match reactions {
-
            Some(reactions) => {
-
                ui.text_view_with_footer(frame, text, reactions, &mut cursor, Some(Borders::All))
-
            }
-
            None => ui.text_view(frame, text, &mut cursor, Some(Borders::All)),
-
        };
-

-
        if comment.changed {
-
            ui.send_message(Message::Changed(Change::CommentBody {
-
                state: TextViewState::new(cursor),
-
            }))
-
        }
-
    }
-

-
    pub fn show_comment_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.column_bar(
-
            frame,
-
            [
-
                Column::new(
-
                    Span::raw(" Comment ".to_string()).cyan().dim().reversed(),
-
                    Constraint::Length(9),
-
                ),
-
                Column::new(
-
                    Span::raw(" ".to_string())
-
                        .into_left_aligned_line()
-
                        .style(ui.theme().bar_on_black_style),
-
                    Constraint::Fill(1),
-
                ),
-
            ]
-
            .to_vec(),
-
            Spacing::from(0),
-
            Some(Borders::None),
-
        );
-
    }
-

-
    pub fn show_comment_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        let shortcuts = vec![("e", "edit"), ("c", "reply")];
-
        let global_shortcuts = vec![("p", "toggle preview"), ("?", "help")];
-

-
        ui.layout(
-
            Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]),
-
            None,
-
            |ui| {
-
                ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left);
-
                ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right);
-
            },
-
        );
-
    }
-

-
    fn show_help_text(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.column_bar(
-
            frame,
-
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
-
            Spacing::from(0),
-
            Some(Borders::Top),
-
        );
-

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

-
    fn show_help_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.column_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(),
-
            Spacing::from(0),
-
            Some(Borders::None),
-
        );
-
    }
-
}
deleted bin/commands/patch.rs
@@ -1,716 +0,0 @@
-
#[path = "patch/list.rs"]
-
mod list;
-
#[path = "patch/review.rs"]
-
mod review;
-

-
use std::ffi::OsString;
-

-
use anyhow::anyhow;
-

-
use radicle::cob::ObjectId;
-
use radicle::identity::RepoId;
-
use radicle::patch::{Patch, Revision, RevisionId, Status};
-
use radicle::prelude::Did;
-
use radicle::storage::git::Repository;
-

-
use radicle_cli::git::Rev;
-
use radicle_cli::terminal::args;
-
use radicle_cli::terminal::args::{string, Args, Error, Help};
-

-
use crate::terminal;
-
use crate::ui::items::filter::DidFilter;
-
use crate::ui::items::patch::filter::PatchFilter;
-

-
pub const HELP: Help = Help {
-
    name: "patch",
-
    description: "Terminal interfaces for patches",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

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

-
List options
-

-
    --all                   Show all patches, including merged and archived patches
-
    --archived              Show only archived patches
-
    --merged                Show only merged patches
-
    --open                  Show only open patches (default)
-
    --draft                 Show only draft patches
-
    --authored              Show only patches that you have authored
-
    --author <did>          Show only patched where the given user is an author
-
                            (may be specified multiple times)
-

-
    --json                  Return JSON on stderr instead of calling `rad`
-

-
Other options
-

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

-
#[derive(Debug, PartialEq)]
-
pub struct Options {
-
    op: Operation,
-
    repo: Option<RepoId>,
-
}
-

-
#[derive(Debug, PartialEq)]
-
pub enum Operation {
-
    List { opts: ListOptions },
-
    Review { opts: ReviewOptions },
-
    Unknown { args: Vec<OsString> },
-
    Other { args: Vec<OsString> },
-
}
-

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

-
#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub struct ListOptions {
-
    filter: ListFilter,
-
    json: bool,
-
}
-

-
#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub struct ReviewOptions {
-
    edit: bool,
-
    patch_id: Option<Rev>,
-
    revision_id: Option<Rev>,
-
}
-

-
impl ReviewOptions {
-
    pub fn revision_or_latest<'a>(
-
        &'a self,
-
        patch: &'a Patch,
-
        repo: &Repository,
-
    ) -> anyhow::Result<(RevisionId, &'a Revision)> {
-
        let revision_id = self
-
            .revision_id
-
            .as_ref()
-
            .map(|rev| rev.resolve::<radicle::git::Oid>(&repo.backend))
-
            .transpose()?
-
            .map(radicle::cob::patch::RevisionId::from);
-

-
        match revision_id {
-
            Some(id) => Ok((
-
                id,
-
                patch
-
                    .revision(&id)
-
                    .ok_or_else(|| anyhow!("Patch revision `{id}` not found"))?,
-
            )),
-
            None => Ok((patch.latest().0, patch.latest().1)),
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub struct ListFilter {
-
    state: Option<Status>,
-
    authored: bool,
-
    authors: Vec<Did>,
-
}
-

-
impl Default for ListFilter {
-
    fn default() -> Self {
-
        Self {
-
            state: Some(Status::Open),
-
            authored: false,
-
            authors: vec![],
-
        }
-
    }
-
}
-

-
impl ListFilter {
-
    pub fn is_default(&self) -> bool {
-
        *self == ListFilter::default()
-
    }
-

-
    pub fn with_state(mut self, status: Option<Status>) -> Self {
-
        self.state = status;
-
        self
-
    }
-

-
    pub fn with_authored(mut self, authored: bool) -> Self {
-
        self.authored = authored;
-
        self
-
    }
-

-
    pub fn with_author(mut self, author: Did) -> Self {
-
        self.authors.push(author);
-
        self
-
    }
-
}
-

-
#[allow(clippy::from_over_into)]
-
impl Into<PatchFilter> for (Did, ListFilter) {
-
    fn into(self) -> PatchFilter {
-
        let (me, mut filter) = self;
-
        let mut and = filter
-
            .state
-
            .map(|s| vec![PatchFilter::State(s)])
-
            .unwrap_or(vec![]);
-

-
        let mut dids = filter.authored.then_some(vec![me]).unwrap_or_default();
-
        dids.append(&mut filter.authors);
-

-
        if dids.len() == 1 {
-
            and.push(PatchFilter::Author(DidFilter::Single(
-
                *dids.first().unwrap(),
-
            )));
-
        } else if dids.len() > 1 {
-
            and.push(PatchFilter::Author(DidFilter::Or(dids)));
-
        }
-

-
        PatchFilter::And(and)
-
    }
-
}
-

-
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.clone());
-
        let mut op = OperationName::List;
-
        let mut forward = None;
-
        let mut json = false;
-
        let mut help = false;
-
        let mut edit = false;
-
        let mut repo = None;
-
        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') => {
-
                    help = true;
-
                    // Only enable forwarding if it was not already disabled explicitly
-
                    forward = match forward {
-
                        Some(false) => Some(false),
-
                        _ => Some(true),
-
                    };
-
                }
-

-
                Long("all") if op == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_state(None);
-
                }
-
                Long("draft") if op == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_state(Some(Status::Draft));
-
                }
-
                Long("archived") if op == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_state(Some(Status::Archived));
-
                }
-
                Long("merged") if op == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_state(Some(Status::Merged));
-
                }
-
                Long("open") if op == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_state(Some(Status::Open));
-
                }
-
                Long("authored") if op == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_authored(true);
-
                }
-
                Long("author") if op == OperationName::List => {
-
                    list_opts.filter = list_opts.filter.with_author(args::did(&parser.value()?)?);
-
                }
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let rid = args::rid(&val)?;
-

-
                    repo = Some(rid);
-
                }
-
                Long("revision") => {
-
                    let val = parser.value()?;
-
                    let rev_id = args::rev(&val)?;
-

-
                    revision_id = Some(rev_id);
-
                }
-
                Long("edit") => {
-
                    edit = true;
-
                }
-
                Value(val) if op == OperationName::List => match val.to_string_lossy().as_ref() {
-
                    "list" => op = OperationName::List,
-
                    _ => {
-
                        op = OperationName::Unknown;
-
                        // Only enable forwarding if it was not already disabled explicitly
-
                        forward = match forward {
-
                            Some(false) => Some(false),
-
                            _ => Some(true),
-
                        };
-
                    }
-
                },
-
                Value(val) if patch_id.is_none() => {
-
                    let val = string(&val);
-
                    patch_id = Some(Rev::from(val));
-
                }
-
                _ => match op {
-
                    OperationName::List | OperationName::Review => {
-
                        return Err(anyhow!(arg.unexpected()));
-
                    }
-
                    _ => {}
-
                },
-
            }
-
        }
-

-
        // 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
-
        list_opts.json = json;
-

-
        // Map local commands. Forward help and ignore `no-forward`.
-
        let op = match op {
-
            OperationName::Review if !forward => Operation::Review {
-
                opts: ReviewOptions {
-
                    edit,
-
                    patch_id,
-
                    revision_id,
-
                },
-
            },
-
            OperationName::List if !forward => Operation::List { opts: list_opts },
-
            OperationName::Unknown if !forward => Operation::Unknown { args },
-
            _ => Operation::Other { args },
-
        };
-

-
        Ok((Options { op, repo }, vec![]))
-
    }
-
}
-

-
#[tokio::main]
-
pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) -> anyhow::Result<()> {
-
    use radicle::storage::ReadStorage;
-

-
    let (_, rid) = radicle::rad::cwd()
-
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
-

-
    if let Err(err) = crate::log::enable() {
-
        println!("{err}");
-
    }
-

-
    match options.op {
-
        Operation::List { opts } => {
-
            log::info!("Starting patch listing app in project {rid}..");
-

-
            let rid = options.repo.unwrap_or(rid);
-
            interface::list(opts.clone(), ctx.profile()?, rid).await?;
-
        }
-
        Operation::Review { ref opts } => {
-
            log::info!("Starting patch review app in project {rid}..");
-

-
            let profile = ctx.profile()?;
-
            let rid = options.repo.unwrap_or(rid);
-
            let repo = profile.storage.repository(rid).unwrap();
-

-
            let patch_id: ObjectId = if let Some(patch_id) = &opts.patch_id {
-
                patch_id.resolve(&repo.backend)?
-
            } else {
-
                anyhow::bail!("a patch must be provided");
-
            };
-

-
            // Run TUI with patch review interface
-
            interface::review(opts.clone(), profile, rid, patch_id).await?;
-
        }
-
        Operation::Other { args } => {
-
            terminal::run_rad(Some("patch"), &args)?;
-
        }
-
        Operation::Unknown { .. } => {
-
            anyhow::bail!("unknown operation provided");
-
        }
-
    }
-

-
    Ok(())
-
}
-

-
mod interface {
-
    use anyhow::anyhow;
-

-
    use radicle::cob::patch::cache::Patches;
-
    use radicle::identity::RepoId;
-
    use radicle::patch;
-
    use radicle::patch::{PatchId, Verdict};
-
    use radicle::storage::git::cob::DraftStore;
-
    use radicle::storage::ReadStorage;
-
    use radicle::Profile;
-

-
    use crate::cob;
-
    use crate::terminal;
-
    use crate::tui_patch::list;
-
    use crate::tui_patch::review::builder::CommentBuilder;
-
    use crate::tui_patch::review::ReviewAction;
-
    use crate::tui_patch::review::ReviewMode;
-

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

-
    pub async fn list(opts: ListOptions, profile: Profile, rid: RepoId) -> anyhow::Result<()> {
-
        let me = profile.did();
-

-
        #[derive(Default)]
-
        struct PreviousState {
-
            patch_id: Option<PatchId>,
-
            search: Option<String>,
-
        }
-

-
        // Store issue and comment selection across app runs in order to
-
        // preselect them when re-running the app.
-
        let mut state = PreviousState::default();
-

-
        loop {
-
            let context = list::Context {
-
                profile: profile.clone(),
-
                rid,
-
                filter: (me, opts.filter.clone()).into(),
-
                search: state.search.clone(),
-
                patch_id: state.patch_id,
-
            };
-

-
            // Run TUI with patch list interface
-
            let selection = list::Tui::new(context).run().await?;
-

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

-
                log::info!("Exiting patch list app..");
-

-
                eprint!("{selection}");
-

-
                break;
-
            } else if let Some(selection) = selection {
-
                if let Some(operation) = selection.operation.clone() {
-
                    match operation {
-
                        list::PatchOperation::Show { args } => {
-
                            state = PreviousState {
-
                                patch_id: Some(args.id()),
-
                                search: Some(args.search()),
-
                            };
-
                            terminal::run_rad(
-
                                Some("patch"),
-
                                &["show".into(), args.id().to_string().into()],
-
                            )?;
-
                        }
-
                        list::PatchOperation::Diff { args } => {
-
                            let repo = profile.clone().storage.repository(rid)?;
-
                            let cache = profile.patches(&repo)?;
-
                            let patch = cache
-
                                .get(&args.id())?
-
                                .ok_or_else(|| anyhow!("unknown patch '{}'", args.id()))?;
-
                            let range = format!("{}..{}", patch.base(), patch.head());
-

-
                            state = PreviousState {
-
                                patch_id: Some(args.id()),
-
                                search: Some(args.search()),
-
                            };
-

-
                            let _ = terminal::run_git(Some("diff"), &[range.into()]);
-
                        }
-
                        list::PatchOperation::Checkout { args } => {
-
                            state = PreviousState {
-
                                patch_id: Some(args.id()),
-
                                search: Some(args.search()),
-
                            };
-
                            terminal::run_rad(
-
                                Some("patch"),
-
                                &["checkout".into(), args.id().to_string().into()],
-
                            )?;
-
                        }
-
                        list::PatchOperation::_Review { args } => {
-
                            state = PreviousState {
-
                                patch_id: Some(args.id()),
-
                                search: Some(args.search()),
-
                            };
-
                            let opts = ReviewOptions::default();
-
                            review(opts, profile.clone(), rid, args.id()).await?;
-
                        }
-
                    }
-
                }
-
            } else {
-
                break;
-
            }
-
        }
-

-
        Ok(())
-
    }
-

-
    pub async fn review(
-
        opts: ReviewOptions,
-
        profile: Profile,
-
        rid: RepoId,
-
        patch_id: PatchId,
-
    ) -> anyhow::Result<()> {
-
        use radicle_cli::terminal;
-

-
        let repo = profile.storage.repository(rid)?;
-
        let signer = terminal::signer(&profile)?;
-
        let cache = profile.patches(&repo)?;
-

-
        let patch = cache
-
            .get(&patch_id)?
-
            .ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;
-
        let (_, revision) = opts.revision_or_latest(&patch, &repo)?;
-
        let hunks = ReviewBuilder::new(&repo).hunks(revision)?;
-

-
        let drafts = DraftStore::new(&repo, *signer.public_key());
-
        let mut patches = patch::Cache::no_cache(&drafts)?;
-
        let mut patch = patches.get_mut(&patch_id)?;
-

-
        if let Some(review) = revision.review_by(signer.public_key()) {
-
            // Review already finalized. Do nothing and warn.
-
            terminal::warning(format!(
-
                "Review ({}) already finalized. Exiting.",
-
                review.id()
-
            ));
-

-
            return Ok(());
-
        };
-

-
        let mode = if opts.edit {
-
            if let Some((id, _)) = cob::find_review(&patch, revision, &signer) {
-
                // Review already started, resume.
-
                log::info!("Resuming review {id}..");
-

-
                ReviewMode::Edit { resume: true }
-
            } else {
-
                // No review to resume, start a new one.
-
                let id = patch.review(
-
                    revision.id(),
-
                    // This is amended before the review is finalized, if all hunks are
-
                    // accepted. We can't set this to `None`, as that will be invalid without
-
                    // a review summary.
-
                    Some(Verdict::Reject),
-
                    None,
-
                    vec![],
-
                    &signer,
-
                )?;
-
                log::info!("Starting new review {id}..");
-

-
                ReviewMode::Edit { resume: false }
-
            }
-
        } else {
-
            ReviewMode::Show
-
        };
-

-
        loop {
-
            // Reload review
-
            let signer = profile.signer()?;
-
            let (review_id, review) = cob::find_review(&patch, revision, &signer)
-
                .ok_or_else(|| anyhow!("Could not find review."))?;
-

-
            let response = review::Tui::new(
-
                mode.clone(),
-
                profile.storage.clone(),
-
                rid,
-
                patch_id,
-
                patch.title().to_string(),
-
                revision.clone(),
-
                review.clone(),
-
                hunks.clone(),
-
            )
-
            .run()
-
            .await?;
-

-
            log::debug!("Received response from TUI: {response:?}");
-

-
            if let Some(response) = response.as_ref() {
-
                if let Some(ReviewAction::Comment) = response.action {
-
                    let hunk = response
-
                        .state
-
                        .selected_hunk()
-
                        .ok_or_else(|| anyhow!("expected a selected hunk"))?;
-
                    let item = hunks
-
                        .get(hunk)
-
                        .ok_or_else(|| anyhow!("expected a hunk to comment on"))?;
-

-
                    let (old, new) = item.paths();
-
                    let path = old.or(new);
-

-
                    if let (Some(hunk), Some((path, _))) = (item.hunk(), path) {
-
                        let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
-
                        let comments = builder.edit(hunk)?;
-

-
                        let signer = profile.signer()?;
-
                        patch.transaction("Review comments", &signer, |tx| {
-
                            for comment in comments {
-
                                tx.review_comment(
-
                                    review_id,
-
                                    comment.body,
-
                                    Some(comment.location),
-
                                    None,   // Not a reply.
-
                                    vec![], // No embeds.
-
                                )?;
-
                            }
-
                            Ok(())
-
                        })?;
-
                    } else {
-
                        log::warn!("Commenting on binary blobs is not yet implemented");
-
                    }
-
                } else {
-
                    break;
-
                }
-
            } else {
-
                break;
-
            }
-
        }
-

-
        Ok(())
-
    }
-
}
-

-
#[cfg(test)]
-
mod cli {
-
    use radicle_cli::terminal::args::Error;
-
    use radicle_cli::terminal::Args;
-

-
    use super::{ListOptions, Operation, Options};
-

-
    #[test]
-
    fn empty_operation_should_default_to_list_and_not_be_forwarded(
-
    ) -> Result<(), Box<dyn std::error::Error>> {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec![];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn empty_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["--help".into()];
-
        let expected_op = Operation::Other { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn empty_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let args = vec!["--help".into(), "--no-forward".into()];
-

-
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
-
        assert!(matches!(actual, Error::Help));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn empty_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec!["--no-forward".into()];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec!["list".into()];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_should_not_be_forwarded_explicitly() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let expected_op = Operation::List {
-
            opts: ListOptions::default(),
-
        };
-

-
        let args = vec!["list".into(), "--no-forward".into()];
-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_with_help_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["list".into(), "--help".into()];
-
        let expected_op = Operation::Other { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_with_help_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>>
-
    {
-
        let args = vec!["list".into(), "--help".into(), "--no-forward".into()];
-
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
-

-
        assert!(matches!(actual, Error::Help));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn list_operation_with_help_should_not_be_forwarded_reversed(
-
    ) -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["list".into(), "--no-forward".into(), "--help".into()];
-
        let actual = Options::from_args(args).unwrap_err().downcast::<Error>()?;
-

-
        assert!(matches!(actual, Error::Help));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn unknown_operation_should_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["operation".into()];
-
        let expected_op = Operation::Other { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn unknown_operation_should_not_be_forwarded() -> Result<(), Box<dyn std::error::Error>> {
-
        let args = vec!["operation".into(), "--no-forward".into()];
-
        let expected_op = Operation::Unknown { args: args.clone() };
-

-
        let (actual, _) = Options::from_args(args)?;
-
        assert_eq!(actual.op, expected_op);
-

-
        Ok(())
-
    }
-
}
deleted bin/commands/patch/list.rs
@@ -1,826 +0,0 @@
-
use std::str::FromStr;
-
use std::sync::{Arc, Mutex};
-

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

-
use radicle::prelude::RepoId;
-
use radicle::storage::ReadStorage;
-
use ratatui::widgets::Clear;
-
use serde::Serialize;
-

-
use radicle::patch::cache::Patches;
-
use radicle::patch::PatchId;
-
use radicle::Profile;
-

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

-
use radicle_tui as tui;
-

-
use tui::event::Key;
-
use tui::store;
-
use tui::task::{Process, Task};
-
use tui::ui;
-
use tui::ui::layout::Spacing;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::{
-
    Borders, Column, ContainerState, TableState, TextEditState, TextViewState, Window,
-
};
-
use tui::ui::{BufferedValue, Show, Ui};
-
use tui::{Channel, Exit};
-

-
use crate::settings;
-
use crate::ui::items::filter::Filter;
-
use crate::ui::items::patch::filter::PatchFilter;
-
use crate::ui::items::patch::Patch;
-

-
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`:      Cancel
-
`q`:        Quit
-

-
# Specific keybindings
-

-
`enter`:    Show patch
-
`c`:        Checkout patch
-
`d`:        Show patch diff
-
`/`:        Search
-
`?`:        Show help
-

-
# Searching
-

-
Examples:   state=open bugfix
-
            state=merged author=(did:key:... or did:key:...)"#;
-

-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub struct OperationArguments {
-
    id: PatchId,
-
    search: String,
-
}
-

-
impl OperationArguments {
-
    pub fn id(&self) -> PatchId {
-
        self.id
-
    }
-

-
    pub fn search(&self) -> String {
-
        self.search.clone()
-
    }
-
}
-

-
impl TryFrom<(&Vec<Patch>, &AppState)> for OperationArguments {
-
    type Error = anyhow::Error;
-

-
    fn try_from(value: (&Vec<Patch>, &AppState)) -> Result<Self> {
-
        let (patches, state) = value;
-
        let selected = state.patches.selected();
-
        let id = selected
-
            .and_then(|s| patches.get(s))
-
            .ok_or(anyhow!("No patch selected"))?
-
            .id;
-
        let search = state.search.read().text;
-

-
        Ok(Self { id, search })
-
    }
-
}
-

-
/// The selected patch operation returned by the operation
-
/// selection widget.
-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub enum PatchOperation {
-
    Checkout { args: OperationArguments },
-
    Diff { args: OperationArguments },
-
    Show { args: OperationArguments },
-
    _Review { args: OperationArguments },
-
}
-

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

-
#[derive(Clone, Debug)]
-
pub struct Context {
-
    pub profile: Profile,
-
    pub rid: RepoId,
-
    pub filter: PatchFilter,
-
    pub patch_id: Option<PatchId>,
-
    pub search: Option<String>,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct Loader {
-
    context: Context,
-
}
-

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

-
#[derive(Debug)]
-
pub struct PatchLoader {
-
    context: Context,
-
}
-

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

-
impl Task for PatchLoader {
-
    type Return = Message;
-

-
    fn run(&self) -> anyhow::Result<Vec<Self::Return>> {
-
        let context = &self.context;
-
        let profile = context.profile.clone();
-
        let repo = profile.storage.repository(context.rid)?;
-
        let cache = profile.patches(&repo)?;
-
        let patches = cache
-
            .list()?
-
            .filter_map(|patch| patch.ok())
-
            .flat_map(|patch| Patch::new(&context.profile, &repo, patch.clone()).ok())
-
            .collect::<Vec<_>>();
-

-
        Ok(vec![Message::Loaded(patches)])
-
    }
-
}
-

-
impl Process<Message> for Loader {
-
    async fn process(&mut self, message: Message) -> anyhow::Result<Vec<Message>> {
-
        match message {
-
            Message::Initialize | Message::Reload => {
-
                let loader = PatchLoader::new(self.context.clone());
-
                let messages = tokio::spawn(async move { loader.run() }).await.unwrap()?;
-
                Ok(messages)
-
            }
-
            _ => Ok(vec![]),
-
        }
-
    }
-
}
-

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

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

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let viewport = Viewport::Inline(20);
-
        let channel = Channel::default();
-
        let state = App::try_from(&self.context)?;
-

-
        tui::im(
-
            state,
-
            viewport,
-
            channel,
-
            vec![Loader::new(self.context.clone())],
-
        )
-
        .await
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Change {
-
    Page {
-
        page: Page,
-
    },
-
    MainGroup {
-
        state: ContainerState,
-
    },
-
    Patches {
-
        state: TableState,
-
    },
-
    Search {
-
        search: BufferedValue<TextEditState>,
-
    },
-
    Help {
-
        state: TextViewState,
-
    },
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Initialize,
-
    Changed(Change),
-
    ShowSearch,
-
    HideSearch { apply: bool },
-
    Reload,
-
    Loaded(Vec<Patch>),
-
    Exit { operation: Option<PatchOperation> },
-
    Quit,
-
}
-

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

-
#[derive(Clone, Debug)]
-
pub struct AppState {
-
    page: Page,
-
    main_group: ContainerState,
-
    patches: TableState,
-
    search: BufferedValue<TextEditState>,
-
    show_search: bool,
-
    help: TextViewState,
-
    filter: PatchFilter,
-
    loading: bool,
-
    initialized: bool,
-
    theme: Theme,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct App {
-
    patches: Arc<Mutex<Vec<Patch>>>,
-
    state: AppState,
-
}
-

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

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

-
        let repo = &context.profile.storage.repository(context.rid)?;
-
        let cache = &context.profile.patches(repo)?;
-
        let mut patches = cache
-
            .list()?
-
            .filter_map(|patch| patch.ok())
-
            .flat_map(|patch| Patch::without_stats(&context.profile, patch.clone()).ok())
-
            .collect::<Vec<_>>();
-
        patches.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
-

-
        let search = context.search.as_ref().map(|s| s.trim().to_string());
-
        let (search, filter) = match search {
-
            Some(search) => (
-
                search.clone(),
-
                PatchFilter::from_str(search.trim()).unwrap_or(PatchFilter::Invalid),
-
            ),
-
            None => {
-
                let filter = context.filter.clone();
-
                (filter.to_string().trim().to_string(), filter)
-
            }
-
        };
-

-
        Ok(App {
-
            patches: Arc::new(Mutex::new(patches.clone())),
-
            state: AppState {
-
                page: Page::Main,
-
                main_group: ContainerState::new(3, Some(0)),
-
                patches: TableState::new(Some(
-
                    context
-
                        .patch_id
-
                        .and_then(|id| {
-
                            patches
-
                                .iter()
-
                                .filter(|item| filter.matches(item))
-
                                .position(|item| item.id == id)
-
                        })
-
                        .unwrap_or(0),
-
                )),
-
                search: BufferedValue::new(TextEditState {
-
                    text: search.clone(),
-
                    cursor: search.len(),
-
                }),
-
                show_search: false,
-
                help: TextViewState::new(Position::default()),
-
                filter,
-
                loading: false,
-
                initialized: false,
-
                theme,
-
            },
-
        })
-
    }
-
}
-

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

-
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
-
        match message {
-
            Message::Initialize => {
-
                self.state.loading = true;
-
                self.state.initialized = true;
-
                None
-
            }
-
            Message::Reload => {
-
                self.state.loading = true;
-
                None
-
            }
-
            Message::Loaded(patches) => {
-
                self.apply_patches(patches);
-
                self.apply_sorting();
-
                self.state.loading = false;
-
                None
-
            }
-
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => Some(Exit {
-
                value: Some(Selection {
-
                    operation,
-
                    args: vec![],
-
                }),
-
            }),
-
            Message::ShowSearch => {
-
                self.state.main_group = ContainerState::new(3, None);
-
                self.state.show_search = true;
-
                None
-
            }
-
            Message::HideSearch { apply } => {
-
                self.state.main_group = ContainerState::new(3, Some(0));
-
                self.state.show_search = false;
-

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

-
                self.state.filter = PatchFilter::from_str(&self.state.search.read().text)
-
                    .unwrap_or(PatchFilter::Invalid);
-

-
                None
-
            }
-
            Message::Changed(changed) => match changed {
-
                Change::Page { page } => {
-
                    self.state.page = page;
-
                    None
-
                }
-
                Change::MainGroup { state } => {
-
                    self.state.main_group = state;
-
                    None
-
                }
-
                Change::Patches { state } => {
-
                    self.state.patches = state;
-
                    None
-
                }
-
                Change::Search { search } => {
-
                    self.state.search = search;
-
                    self.state.filter = PatchFilter::from_str(&self.state.search.read().text)
-
                        .unwrap_or(PatchFilter::Invalid);
-
                    self.state.patches.select_first();
-
                    None
-
                }
-
                Change::Help { state } => {
-
                    self.state.help = state;
-
                    None
-
                }
-
            },
-
        }
-
    }
-
}
-

-
impl Show<Message> for App {
-
    fn show(&self, ctx: &ui::Context<Message>, frame: &mut Frame) -> Result<()> {
-
        Window::default().show(ctx, self.state.theme.clone(), |ui| {
-
            // Initialize
-
            if !self.state.initialized {
-
                ui.send_message(Message::Initialize);
-
            }
-

-
            match self.state.page {
-
                Page::Main => {
-
                    let show_search = self.state.show_search;
-
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
-

-
                    ui.container(
-
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
-
                        &mut page_focus,
-
                        |ui| {
-
                            let mut group_focus = self.state.main_group.focus();
-

-
                            let group = ui.container(
-
                                ui::Layout::Expandable3 { left_only: true },
-
                                &mut group_focus,
-
                                |ui| {
-
                                    self.show_browser(frame, ui);
-
                                },
-
                            );
-
                            if group.response.changed {
-
                                ui.send_message(Message::Changed(Change::MainGroup {
-
                                    state: ContainerState::new(3, group_focus),
-
                                }));
-
                            }
-

-
                            if show_search {
-
                                self.show_browser_search(frame, ui);
-
                            } else if let Some(0) = group_focus {
-
                                self.show_browser_footer(frame, ui);
-
                            }
-
                        },
-
                    );
-

-
                    if !show_search && ui.has_input(|key| key == Key::Char('?')) {
-
                        ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
-
                    }
-
                }
-

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

-
                    ui.container(layout, &mut Some(1), |ui| {
-
                        self.show_help_text(frame, ui);
-
                        self.show_help_context(frame, ui);
-

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

-
                    if ui.has_input(|key| key == Key::Char('?')) {
-
                        ui.send_message(Message::Changed(Change::Page { page: Page::Main }));
-
                    }
-
                }
-
            }
-
            if ui.has_input(|key| key == Key::Char('q')) {
-
                ui.send_message(Message::Quit);
-
            }
-
            if ui.has_input(|key| key == Key::Ctrl('c')) {
-
                ui.send_message(Message::Quit);
-
            }
-
        });
-

-
        Ok(())
-
    }
-
}
-

-
impl App {
-
    pub fn show_browser(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        let patches = self.patches.lock().unwrap();
-
        let patches = patches
-
            .iter()
-
            .filter(|patch| self.state.filter.matches(patch))
-
            .cloned()
-
            .collect::<Vec<_>>();
-
        let mut selected = self.state.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(),
-
        ];
-

-
        ui.layout(
-
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
-
            Some(1),
-
            |ui| {
-
                ui.column_bar(frame, header.to_vec(), Spacing::from(1), Some(Borders::Top));
-

-
                let table = ui.table(
-
                    frame,
-
                    &mut selected,
-
                    &patches,
-
                    header.to_vec(),
-
                    Some("No patches found".into()),
-
                    Spacing::from(1),
-
                    Some(Borders::BottomSides),
-
                );
-
                if table.changed {
-
                    ui.send_message(Message::Changed(Change::Patches {
-
                        state: TableState::new(selected),
-
                    }));
-
                }
-

-
                if self.state.loading {
-
                    self.show_loading_popup(frame, ui);
-
                }
-
            },
-
        );
-

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

-
        if let Ok(args) = OperationArguments::try_from((&patches, &self.state)) {
-
            if ui.has_input(|key| key == Key::Enter) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(PatchOperation::Show { args: args.clone() }),
-
                });
-
            }
-
            if ui.has_input(|key| key == Key::Char('d')) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(PatchOperation::Diff { args: args.clone() }),
-
                });
-
            }
-
            if ui.has_input(|key| key == Key::Char('c')) {
-
                ui.send_message(Message::Exit {
-
                    operation: Some(PatchOperation::Checkout { args }),
-
                });
-
            }
-
        }
-
    }
-

-
    fn show_browser_footer(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.layout(Layout::vertical([1, 1]), None, |ui| {
-
            self.show_browser_context(frame, ui);
-
            self.show_browser_shortcuts(frame, ui);
-
        });
-
    }
-

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

-
        let text_edit = ui.text_edit_singleline(
-
            frame,
-
            &mut search_text,
-
            &mut search_cursor,
-
            Some("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::Changed(Change::Search { search }));
-
        }
-

-
        if ui.has_input(|key| key == Key::Esc) {
-
            ui.send_message(Message::HideSearch { apply: false });
-
        }
-
        if ui.has_input(|key| key == Key::Enter) {
-
            ui.send_message(Message::HideSearch { apply: true });
-
        }
-
    }
-

-
    fn show_browser_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        let context = {
-
            let patches = self.patches.lock().unwrap();
-
            let search = self.state.search.read().text;
-
            let total_count = patches.len();
-
            let filtered_count = patches
-
                .iter()
-
                .filter(|patch| self.state.filter.matches(patch))
-
                .collect::<Vec<_>>()
-
                .len();
-

-
            let filtered_counts = format!(" {filtered_count}/{total_count} ");
-
            let state_counts =
-
                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 !self.state.filter.has_state() {
-
                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)
-
                            .cyan()
-
                            .dim(),
-
                        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)
-
                            .cyan()
-
                            .dim(),
-
                        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()
-
            }
-
        };
-

-
        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
-
    }
-

-
    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.shortcuts(
-
            frame,
-
            &[
-
                ("enter", "show"),
-
                ("c", "checkout"),
-
                ("d", "diff"),
-
                ("/", "search"),
-
                ("r", "reload"),
-
                ("?", "help"),
-
            ],
-
            '∙',
-
            Alignment::Left,
-
        );
-
    }
-

-
    fn show_loading_popup(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.popup(Layout::vertical([Constraint::Min(1)]), |ui| {
-
            ui.layout(
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).margin(1),
-
                None,
-
                |ui| {
-
                    ui.label(frame, "");
-
                    ui.layout(
-
                        Layout::horizontal([Constraint::Min(1), Constraint::Length(11)]),
-
                        None,
-
                        |ui| {
-
                            ui.label(frame, "");
-
                            ui.layout(Layout::vertical([Constraint::Min(1)]), None, |ui| {
-
                                frame.render_widget(Clear, ui.area());
-
                                ui.column_bar(
-
                                    frame,
-
                                    [Column::new(
-
                                        Span::raw(" Loading ").magenta().rapid_blink(),
-
                                        Constraint::Fill(1),
-
                                    )]
-
                                    .to_vec(),
-
                                    Spacing::from(0),
-
                                    Some(Borders::All),
-
                                );
-
                            });
-
                        },
-
                    );
-
                },
-
            );
-
        });
-
    }
-

-
    fn show_help_text(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.column_bar(
-
            frame,
-
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
-
            Spacing::from(0),
-
            Some(Borders::Top),
-
        );
-

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

-
    fn show_help_context(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        ui.column_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(),
-
            Spacing::from(0),
-
            Some(Borders::None),
-
        );
-
    }
-
}
-

-
impl App {
-
    fn apply_patches(&mut self, patches: Vec<Patch>) {
-
        let mut items = self.patches.lock().unwrap();
-
        *items = patches;
-
    }
-

-
    fn apply_sorting(&mut self) {
-
        let mut items = self.patches.lock().unwrap();
-
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
-
    }
-
}
deleted bin/commands/patch/review.rs
@@ -1,974 +0,0 @@
-
#[path = "review/builder.rs"]
-
pub mod builder;
-

-
use std::fmt::Debug;
-
use std::sync::{Arc, Mutex};
-

-
use anyhow::Result;
-

-
use serde::{Deserialize, Serialize};
-

-
use ratatui::layout::{Alignment, Constraint, Layout, Position};
-
use ratatui::style::{Style, Stylize};
-
use ratatui::text::Text;
-
use ratatui::{Frame, Viewport};
-

-
use radicle::identity::RepoId;
-
use radicle::patch::{PatchId, Review, Revision, RevisionId};
-
use radicle::storage::ReadStorage;
-
use radicle::Storage;
-

-
use radicle_tui as tui;
-

-
use tui::event::Key;
-
use tui::store;
-
use tui::task::EmptyProcessors;
-
use tui::ui::layout::Spacing;
-
use tui::ui::span;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::{Borders, Column, ContainerState, TableState, TextViewState, Window};
-
use tui::ui::{Context, Show, Ui};
-
use tui::{Channel, Exit};
-

-
use crate::git::HunkState;
-
use crate::settings;
-
use crate::state::{self, FileIdentifier, FileStore, ReadState, WriteState};
-
use crate::ui::format;
-
use crate::ui::items::patch::{HunkItem, StatefulHunkItem};
-
use crate::ui::layout;
-

-
use self::builder::Hunks;
-

-
/// The actions that a user can carry out on a review item.
-
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
-
pub enum ReviewAction {
-
    Comment,
-
}
-

-
#[allow(dead_code)]
-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub struct Args(String);
-

-
#[derive(Clone, Debug)]
-
pub struct Response {
-
    pub state: AppState,
-
    pub action: Option<ReviewAction>,
-
}
-

-
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
-
pub enum ReviewMode {
-
    Show,
-
    Edit { resume: bool },
-
}
-

-
pub struct Tui {
-
    pub mode: ReviewMode,
-
    pub storage: Storage,
-
    pub rid: RepoId,
-
    pub patch: PatchId,
-
    pub title: String,
-
    pub revision: Revision,
-
    pub review: Review,
-
    pub hunks: Hunks,
-
}
-

-
impl Tui {
-
    #[allow(clippy::too_many_arguments)]
-
    pub fn new(
-
        mode: ReviewMode,
-
        storage: Storage,
-
        rid: RepoId,
-
        patch: PatchId,
-
        title: String,
-
        revision: Revision,
-
        review: Review,
-
        hunks: Hunks,
-
    ) -> Self {
-
        Self {
-
            mode,
-
            storage,
-
            rid,
-
            patch,
-
            title,
-
            revision,
-
            review,
-
            hunks,
-
        }
-
    }
-

-
    pub async fn run(self) -> Result<Option<Response>> {
-
        let viewport = Viewport::Fullscreen;
-
        let channel = Channel::default();
-

-
        let identifier = FileIdentifier::new("patch", "review", &self.rid, Some(&self.patch));
-
        let store = FileStore::new(identifier)?;
-

-
        let default = AppState::new(
-
            ReviewMode::Show,
-
            self.rid,
-
            self.patch,
-
            self.title,
-
            self.revision.id(),
-
            &self.hunks,
-
        );
-

-
        let state = store
-
            .read()
-
            .map(|bytes| state::from_json::<AppState>(&bytes).ok())?
-
            .unwrap_or(default);
-

-
        let app = App::new(self.storage, self.review, self.hunks, state, self.mode)?;
-
        let response = tui::im(app, viewport, channel, EmptyProcessors::new()).await?;
-

-
        if let Some(response) = response.as_ref() {
-
            store.write(&state::to_json(&response.state)?)?;
-
        }
-

-
        Ok(response)
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    ShowMain,
-
    PanesChanged { state: ContainerState },
-
    HunkChanged { state: TableState },
-
    HunkViewChanged { state: DiffViewState },
-
    ShowHelp,
-
    HelpChanged { state: TextViewState },
-
    Comment,
-
    Accept,
-
    Reject,
-
    Quit,
-
}
-

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

-
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
-
pub struct DiffViewState {
-
    cursor: Position,
-
}
-

-
#[derive(Clone, Debug, Serialize, Deserialize)]
-
pub struct AppState {
-
    /// Review mode: edit or show.
-
    mode: ReviewMode,
-
    /// The repository to operate on.
-
    rid: RepoId,
-
    /// Patch this review belongs to.
-
    patch: PatchId,
-
    /// Patch title.
-
    title: String,
-
    /// Revision this review belongs to.
-
    revision: RevisionId,
-
    /// Current app page.
-
    page: AppPage,
-
    /// State of panes widget on the main page.
-
    panes: ContainerState,
-
    /// The hunks' table widget state.
-
    hunks: (TableState, Vec<HunkState>),
-
    /// Diff view states (cursor position is stored per hunk)
-
    views: Vec<DiffViewState>,
-
    /// State of text view widget on the help page.
-
    help: TextViewState,
-
    /// The active theme
-
    theme: Theme,
-
}
-

-
impl AppState {
-
    pub fn new(
-
        mode: ReviewMode,
-
        rid: RepoId,
-
        patch: PatchId,
-
        title: String,
-
        revision: RevisionId,
-
        hunks: &Hunks,
-
    ) -> Self {
-
        let settings = settings::Settings::default();
-
        let theme = settings::configure_theme(&settings);
-

-
        Self {
-
            mode,
-
            rid,
-
            patch,
-
            title,
-
            revision,
-
            page: AppPage::Main,
-
            panes: ContainerState::new(2, Some(0)),
-
            hunks: (
-
                TableState::new(Some(0)),
-
                vec![HunkState::Rejected; hunks.len()],
-
            ),
-
            views: vec![DiffViewState::default(); hunks.len()],
-
            help: TextViewState::new(Position::default()),
-
            theme,
-
        }
-
    }
-

-
    pub fn view_state(&self, index: usize) -> Option<&DiffViewState> {
-
        self.views.get(index)
-
    }
-

-
    pub fn update_view_state(&mut self, index: usize, state: DiffViewState) {
-
        if let Some(view) = self.views.get_mut(index) {
-
            *view = state;
-
        }
-
    }
-

-
    pub fn update_hunks(&mut self, hunks: TableState) {
-
        self.hunks.0 = hunks;
-
    }
-

-
    pub fn selected_hunk(&self) -> Option<usize> {
-
        self.hunks.0.selected()
-
    }
-

-
    pub fn accept_hunk(&mut self, index: usize) {
-
        if let Some(state) = self.hunks.1.get_mut(index) {
-
            *state = HunkState::Accepted;
-
        }
-
    }
-

-
    pub fn reject_hunk(&mut self, index: usize) {
-
        if let Some(state) = self.hunks.1.get_mut(index) {
-
            *state = HunkState::Rejected;
-
        }
-
    }
-

-
    pub fn hunk_states(&self) -> &Vec<HunkState> {
-
        &self.hunks.1
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct App<'a> {
-
    /// All hunks.
-
    hunks: Arc<Mutex<Vec<StatefulHunkItem<'a>>>>,
-
    /// The app state.
-
    state: Arc<Mutex<AppState>>,
-
}
-

-
impl App<'_> {
-
    pub fn new(
-
        storage: Storage,
-
        review: Review,
-
        hunks: Hunks,
-
        state: AppState,
-
        mode: ReviewMode,
-
    ) -> Result<Self, anyhow::Error> {
-
        let repo = storage.repository(state.rid)?;
-
        // TODO: Check, if it's necessary to protect the app state.
-
        // let mode = match state.mode {
-
        //     ReviewMode::Edit { resume: _ } if mode == ReviewMode::Show => {
-
        //         // TODO: Ask user what to do.
-
        //         anyhow::bail!("Review not finalized, yet. Current state would be lost.")
-
        //     }
-
        //     _ => mode,
-
        // };
-

-
        let hunks = hunks
-
            .iter()
-
            .enumerate()
-
            .map(|(idx, item)| {
-
                StatefulHunkItem::new(
-
                    HunkItem::from((&repo, &review, item)),
-
                    state.hunk_states().get(idx).cloned().unwrap_or_default(),
-
                )
-
            })
-
            .collect::<Vec<_>>();
-

-
        Ok(Self {
-
            hunks: Arc::new(Mutex::new(hunks)),
-
            state: Arc::new(Mutex::new(AppState { mode, ..state })),
-
        })
-
    }
-

-
    pub fn accept_selected_hunk(&mut self) -> Result<()> {
-
        if let Some(selected) = self.selected_hunk() {
-
            let mut state = self.state.lock().unwrap();
-
            state.accept_hunk(selected);
-
        }
-
        self.synchronize_hunk_state();
-

-
        Ok(())
-
    }
-

-
    pub fn reject_selected_hunk(&mut self) -> Result<()> {
-
        if let Some(selected) = self.selected_hunk() {
-
            let mut state = self.state.lock().unwrap();
-
            state.reject_hunk(selected);
-
        }
-
        self.synchronize_hunk_state();
-

-
        Ok(())
-
    }
-

-
    pub fn selected_hunk(&self) -> Option<usize> {
-
        let state = self.state.lock().unwrap();
-
        state.selected_hunk()
-
    }
-

-
    fn synchronize_hunk_state(&mut self) {
-
        let state = self.state.lock().unwrap();
-
        let mut hunks = self.hunks.lock().unwrap();
-

-
        if let Some(selected) = state.selected_hunk() {
-
            if let Some(item) = hunks.get_mut(selected) {
-
                if let Some(state) = state.hunk_states().get(selected) {
-
                    item.update_state(state);
-
                }
-
            }
-
        }
-
    }
-
}
-

-
impl App<'_> {
-
    fn show_hunk_list(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
-
        let hunks = self.hunks.lock().unwrap();
-
        let state = self.state.lock().unwrap();
-

-
        let state_column_width = match state.mode {
-
            ReviewMode::Show => 0,
-
            ReviewMode::Edit { resume: _ } => 2,
-
        };
-
        let header = [Column::new(" Hunks ", Constraint::Fill(1))].to_vec();
-
        let columns = [
-
            Column::new("", Constraint::Length(state_column_width)),
-
            Column::new("", Constraint::Fill(1)),
-
            Column::new("", Constraint::Length(15)),
-
        ]
-
        .to_vec();
-

-
        let mut selected = state.selected_hunk();
-

-
        ui.layout(
-
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
-
            Some(1),
-
            |ui| {
-
                ui.column_bar(
-
                    frame,
-
                    header.to_vec(),
-
                    Spacing::default(),
-
                    Some(Borders::Top),
-
                );
-

-
                let table = ui.table(
-
                    frame,
-
                    &mut selected,
-
                    &hunks,
-
                    columns,
-
                    None,
-
                    Spacing::from(1),
-
                    Some(Borders::BottomSides),
-
                );
-
                if table.changed {
-
                    ui.send_message(Message::HunkChanged {
-
                        state: TableState::new(selected),
-
                    })
-
                }
-
            },
-
        );
-
    }
-

-
    fn show_hunk(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
-
        let hunks = self.hunks.lock().unwrap();
-
        let state = self.state.lock().unwrap();
-

-
        let selected = state.selected_hunk();
-
        let hunk = selected.and_then(|selected| hunks.get(selected));
-

-
        if let Some(hunk) = hunk {
-
            let mut cursor = selected
-
                .and_then(|selected| state.view_state(selected))
-
                .map(|state| state.cursor)
-
                .unwrap_or_default();
-

-
            ui.container(layout::container(), &mut Some(1), |ui| {
-
                ui.column_bar(
-
                    frame,
-
                    hunk.inner().header(),
-
                    Spacing::from(0),
-
                    Some(Borders::Top),
-
                );
-

-
                if let Some(text) = hunk.inner().hunk_text() {
-
                    let diff = ui.text_view(frame, text, &mut cursor, Some(Borders::BottomSides));
-
                    if diff.changed {
-
                        ui.send_message(Message::HunkViewChanged {
-
                            state: DiffViewState { cursor },
-
                        })
-
                    }
-
                } else {
-
                    let empty_text = hunk
-
                        .inner()
-
                        .hunk_text()
-
                        .unwrap_or(Text::raw("Nothing to show.").dark_gray());
-
                    ui.centered_text_view(frame, empty_text, Some(Borders::BottomSides));
-
                }
-
            });
-
        }
-
    }
-

-
    fn show_context_bar(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
-
        let hunks = &self.hunks.lock().unwrap();
-
        let state = self.state.lock().unwrap();
-

-
        let id = format!(" {} ", format::cob(&state.patch));
-
        let title = &state.title;
-

-
        let hunks_total = hunks.len();
-
        let hunks_accepted = state
-
            .hunks
-
            .1
-
            .iter()
-
            .filter(|state| **state == HunkState::Accepted)
-
            .collect::<Vec<_>>()
-
            .len();
-

-
        let (mode, context, context_style) = match state.mode {
-
            ReviewMode::Show => (
-
                " Show ",
-
                "".into(),
-
                Style::default().cyan().dim().reversed(),
-
            ),
-
            ReviewMode::Edit { resume: _ } => (
-
                " Edit ",
-
                format!(" Accepted {hunks_accepted}/{hunks_total} "),
-
                Style::default().light_red().dim().reversed(),
-
            ),
-
        };
-

-
        ui.column_bar(
-
            frame,
-
            [
-
                Column::new(
-
                    span::default(mode).style(context_style),
-
                    Constraint::Length(mode.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    span::default(&id)
-
                        .style(ui.theme().bar_on_black_style)
-
                        .magenta(),
-
                    Constraint::Length(9),
-
                ),
-
                Column::new(
-
                    span::default(title)
-
                        .style(ui.theme().bar_on_black_style)
-
                        .magenta()
-
                        .dim(),
-
                    Constraint::Length(title.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    span::default(" ")
-
                        .into_left_aligned_line()
-
                        .style(ui.theme().bar_on_black_style),
-
                    Constraint::Fill(1),
-
                ),
-
                Column::new(
-
                    span::default(&context)
-
                        .into_right_aligned_line()
-
                        .style(context_style),
-
                    Constraint::Length(context.chars().count() as u16),
-
                ),
-
            ]
-
            .to_vec(),
-
            Spacing::from(0),
-
            Some(Borders::None),
-
        );
-
    }
-

-
    fn show_footer(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
-
        let state = self.state.lock().unwrap();
-
        match state.mode {
-
            ReviewMode::Edit { resume: _ } => {
-
                ui.shortcuts(
-
                    frame,
-
                    &[
-
                        ("c", "comment"),
-
                        ("a", "accept"),
-
                        ("r", "reject"),
-
                        ("?", "help"),
-
                        ("q", "quit"),
-
                    ],
-
                    '∙',
-
                    Alignment::Left,
-
                );
-

-
                if ui.has_input(|key| key == Key::Char('?')) {
-
                    ui.send_message(Message::ShowHelp);
-
                }
-
                if ui.has_input(|key| key == Key::Char('c')) {
-
                    ui.send_message(Message::Comment);
-
                }
-
                if ui.has_input(|key| key == Key::Char('a')) {
-
                    ui.send_message(Message::Accept);
-
                }
-
                if ui.has_input(|key| key == Key::Char('r')) {
-
                    ui.send_message(Message::Reject);
-
                }
-
            }
-
            ReviewMode::Show => {
-
                ui.shortcuts(frame, &[("?", "help"), ("q", "quit")], '∙', Alignment::Left);
-

-
                if ui.has_input(|key| key == Key::Char('?')) {
-
                    ui.send_message(Message::ShowHelp);
-
                }
-
            }
-
        }
-
    }
-
}
-

-
impl Show<Message> for App<'_> {
-
    fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<(), anyhow::Error> {
-
        let (page, theme) = {
-
            let state = self.state.lock().unwrap();
-
            (state.page.clone(), state.theme.clone())
-
        };
-

-
        Window::default().show(ctx, theme, |ui| {
-
            match page {
-
                AppPage::Main => {
-
                    let (mut focus, count) = {
-
                        let state = self.state.lock().unwrap();
-
                        (state.panes.focus(), state.panes.len())
-
                    };
-

-
                    ui.layout(layout::page(), Some(0), |ui| {
-
                        let group = ui.container(layout::list_item(), &mut focus, |ui| {
-
                            self.show_hunk_list(ui, frame);
-
                            self.show_hunk(ui, frame);
-
                        });
-
                        if group.response.changed {
-
                            ui.send_message(Message::PanesChanged {
-
                                state: ContainerState::new(count, focus),
-
                            });
-
                        }
-

-
                        self.show_context_bar(ui, frame);
-
                        self.show_footer(ui, frame);
-
                    });
-
                }
-
                AppPage::Help => {
-
                    ui.layout(layout::page(), Some(0), |ui| {
-
                        ui.container(layout::container(), &mut Some(1), |ui| {
-
                            let mut cursor = {
-
                                let state = self.state.lock().unwrap();
-
                                state.help.cursor()
-
                            };
-
                            let header = [Column::new(" Help ", Constraint::Fill(1))].to_vec();
-

-
                            ui.column_bar(frame, header, Spacing::from(0), Some(Borders::Top));
-
                            let help = ui.text_view(
-
                                frame,
-
                                help_text().to_string(),
-
                                &mut cursor,
-
                                Some(Borders::BottomSides),
-
                            );
-
                            if help.changed {
-
                                ui.send_message(Message::HelpChanged {
-
                                    state: TextViewState::new(cursor),
-
                                })
-
                            }
-
                        });
-

-
                        self.show_context_bar(ui, frame);
-

-
                        ui.shortcuts(
-
                            frame,
-
                            &[("?", "close"), ("q", "quit")],
-
                            '∙',
-
                            Alignment::Left,
-
                        );
-
                    });
-

-
                    if ui.has_input(|key| key == Key::Char('?')) {
-
                        ui.send_message(Message::ShowMain);
-
                    }
-
                }
-
            }
-

-
            if ui.has_input(|key| key == Key::Char('q')) {
-
                ui.send_message(Message::Quit);
-
            }
-
        });
-
        Ok(())
-
    }
-
}
-

-
impl store::Update<Message> for App<'_> {
-
    type Return = Response;
-

-
    fn update(&mut self, message: Message) -> Option<Exit<Self::Return>> {
-
        match message {
-
            Message::ShowMain => {
-
                let mut state = self.state.lock().unwrap();
-
                state.page = AppPage::Main;
-
                None
-
            }
-
            Message::ShowHelp => {
-
                let mut state = self.state.lock().unwrap();
-
                state.page = AppPage::Help;
-
                None
-
            }
-
            Message::PanesChanged { state } => {
-
                let mut app_state = self.state.lock().unwrap();
-
                app_state.panes = state;
-
                None
-
            }
-
            Message::HunkChanged { state } => {
-
                let mut app_state = self.state.lock().unwrap();
-
                app_state.update_hunks(state);
-
                None
-
            }
-
            Message::HunkViewChanged { state } => {
-
                let mut app_state = self.state.lock().unwrap();
-
                if let Some(selected) = app_state.selected_hunk() {
-
                    app_state.update_view_state(selected, state);
-
                }
-
                None
-
            }
-
            Message::HelpChanged { state } => {
-
                let mut app_state = self.state.lock().unwrap();
-
                app_state.help = state;
-
                None
-
            }
-
            Message::Comment => {
-
                let state = self.state.lock().unwrap();
-
                Some(Exit {
-
                    value: Some(Response {
-
                        action: Some(ReviewAction::Comment),
-
                        state: state.clone(),
-
                    }),
-
                })
-
            }
-
            Message::Accept => {
-
                match self.accept_selected_hunk() {
-
                    Ok(()) => log::debug!("Accepted selected hunk ({:?}).", self.selected_hunk()),
-
                    Err(err) => log::error!("An error occured while accepting hunk: {err}"),
-
                }
-
                None
-
            }
-
            Message::Reject => {
-
                match self.reject_selected_hunk() {
-
                    Ok(()) => log::debug!("Rejected selected hunk ({:?}).", self.selected_hunk()),
-
                    Err(err) => log::error!("An error occured while rejecting hunk: {err}"),
-
                }
-
                None
-
            }
-
            Message::Quit => {
-
                let state = self.state.lock().unwrap();
-
                Some(Exit {
-
                    value: Some(Response {
-
                        action: None,
-
                        state: state.clone(),
-
                    }),
-
                })
-
            }
-
        }
-
    }
-
}
-

-
fn help_text() -> String {
-
    r#"# About
-

-
A terminal interface for reviewing patch revisions.
-

-
Starts a new or resumes an existing review for a given revision (default: latest). When the
-
review is done, it needs to be finalized via `rad patch review --accept | --reject <id>`.
-

-
# Keybindings
-

-
`←,h`       move cursor to the left
-
`↑,k`       move cursor one line up
-
`↓,j`       move cursor one line down
-
`→,l`       move cursor to the right
-
`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 pane
-
`BackTab`   Focus previous pane
-

-
`?`         toogle help
-
`q`         quit / cancel
-

-
## Specific keybindings
-

-
`c`         comment on hunk
-
`a`         accept hunk
-
`d`         discard accepted hunks (reject all)"#
-
        .into()
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use anyhow::*;
-

-
    use radicle::patch::Cache;
-

-
    use store::Update;
-

-
    use super::*;
-
    use crate::test;
-

-
    impl App<'_> {
-
        pub fn hunks(&self) -> Vec<StatefulHunkItem<'_>> {
-
            self.hunks.lock().unwrap().clone()
-
        }
-
    }
-

-
    mod fixtures {
-
        use anyhow::*;
-

-
        use radicle::cob::cache::NoCache;
-
        use radicle::patch::{Cache, PatchMut, Review, ReviewId, Revision, Verdict};
-
        use radicle::storage::git::cob::DraftStore;
-
        use radicle::storage::git::Repository;
-

-
        use crate::cob;
-
        use crate::test::setup::NodeWithRepo;
-

-
        use super::builder::ReviewBuilder;
-
        use super::{App, AppState, ReviewMode};
-

-
        pub fn app<'a>(
-
            node: &NodeWithRepo,
-
            patch: PatchMut<Repository, NoCache>,
-
        ) -> Result<App<'a>> {
-
            let draft_store = DraftStore::new(&node.repo.repo, *node.signer.public_key());
-
            let mut drafts = Cache::no_cache(&draft_store)?;
-
            let mut draft = drafts.get_mut(patch.id())?;
-

-
            let (_, revision) = patch.latest();
-
            let (_, review) = draft_review(node, &mut draft, revision)?;
-

-
            let hunks = ReviewBuilder::new(&node.repo).hunks(revision)?;
-

-
            let mode = ReviewMode::Edit { resume: false };
-
            let state = AppState::new(
-
                mode.clone(),
-
                node.repo.id,
-
                *patch.id(),
-
                patch.title().to_string(),
-
                revision.id(),
-
                &hunks,
-
            );
-

-
            App::new(node.storage.clone(), review.clone(), hunks, state, mode)
-
        }
-

-
        pub fn draft_review<'a>(
-
            node: &NodeWithRepo,
-
            draft: &'a mut PatchMut<DraftStore<Repository>, NoCache>,
-
            revision: &Revision,
-
        ) -> Result<(ReviewId, &'a Review)> {
-
            let id = draft.review(
-
                revision.id(),
-
                Some(Verdict::Reject),
-
                None,
-
                vec![],
-
                &node.node.signer,
-
            )?;
-

-
            let (_, review) = cob::find_review(draft, revision, &node.node.signer)
-
                .ok_or_else(|| anyhow!("Could not find review."))?;
-

-
            Ok((id, review))
-
        }
-
    }
-

-
    #[test]
-
    fn app_with_single_hunk_can_be_constructed() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_main_emptied(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let app = fixtures::app(&alice, patch)?;
-

-
        assert_eq!(app.hunks().len(), 1);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn app_with_single_file_multiple_hunks_can_be_constructed() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_eof_removed(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let app = fixtures::app(&alice, patch)?;
-

-
        assert_eq!(app.hunks().len(), 2);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn first_hunk_is_selected_by_default() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_main_emptied(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let app = fixtures::app(&alice, patch)?;
-

-
        assert_eq!(app.selected_hunk(), Some(0));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn hunks_are_rejected_by_default() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_main_deleted_and_file_added(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let app = fixtures::app(&alice, patch)?;
-
        let state = app.state.lock().unwrap();
-
        let states = &state.hunk_states();
-

-
        assert_eq!(**states, [HunkState::Rejected, HunkState::Rejected]);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn hunk_can_be_selected() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_eof_removed(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let mut app = fixtures::app(&alice, patch)?;
-
        app.update(Message::HunkChanged {
-
            state: TableState::new(Some(1)),
-
        });
-

-
        assert_eq!(app.selected_hunk(), Some(1));
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn single_file_single_hunk_can_be_accepted() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_main_emptied(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let mut app = fixtures::app(&alice, patch)?;
-
        app.update(Message::Accept);
-

-
        let state = app.state.lock().unwrap();
-
        let state = &state.hunk_states().first().unwrap();
-

-
        assert_eq!(**state, HunkState::Accepted);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn single_file_multiple_hunks_only_first_can_be_accepted() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_main_changed(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let mut app = fixtures::app(&alice, patch)?;
-
        app.update(Message::Accept);
-

-
        let state = app.state.lock().unwrap();
-
        let states = &state.hunk_states();
-

-
        assert_eq!(**states, [HunkState::Accepted, HunkState::Rejected]);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn single_file_multiple_hunks_only_last_can_be_accepted() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_main_changed(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let mut app = fixtures::app(&alice, patch)?;
-

-
        app.update(Message::HunkChanged {
-
            state: TableState::new(Some(1)),
-
        });
-
        app.update(Message::Accept);
-

-
        let state = app.state.lock().unwrap();
-
        let states = &state.hunk_states();
-

-
        assert_eq!(**states, [HunkState::Rejected, HunkState::Accepted]);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn multiple_files_single_hunk_can_be_accepted() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_main_deleted_and_file_added(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let mut app = fixtures::app(&alice, patch)?;
-
        app.update(Message::Accept);
-

-
        app.update(Message::HunkChanged {
-
            state: TableState::new(Some(1)),
-
        });
-
        app.update(Message::Accept);
-

-
        let state = app.state.lock().unwrap();
-
        let states = &state.hunk_states();
-

-
        assert_eq!(**states, [HunkState::Accepted, HunkState::Accepted]);
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn hunk_state_is_synchronized() -> Result<()> {
-
        let alice = test::fixtures::node_with_repo();
-
        let branch = test::fixtures::branch_with_main_changed(&alice);
-

-
        let mut patches = Cache::no_cache(&alice.repo.repo).unwrap();
-
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;
-

-
        let mut app = fixtures::app(&alice, patch)?;
-
        app.update(Message::Accept);
-

-
        let state = app.state.lock().unwrap();
-
        let hunks = app.hunks.lock().unwrap();
-

-
        let item_states = hunks
-
            .iter()
-
            .map(|item| item.state().clone())
-
            .collect::<Vec<_>>();
-
        let states = &state.hunk_states();
-

-
        assert_eq!(**states, item_states);
-

-
        Ok(())
-
    }
-
}
deleted bin/commands/patch/review/builder.rs
@@ -1,694 +0,0 @@
-
//! Review builder.
-
//!
-
//! This module enables a user to review a patch by interactively viewing and accepting diff hunks.
-
//!
-
//! To implement this behavior, we keep a hidden Git tree object that tracks the state of the
-
//! repository including the accepted hunks. Thus, every time a diff hunk is accepted, it is applied
-
//! to that tree. We call that tree the "brain", as it tracks what the code reviewer has reviewed.
-
//!
-
//! The brain starts out equalling the tree of the base branch, and eventually, when the brain
-
//! matches the tree of the patch being reviewed (by accepting hunks), we can say that the patch has
-
//! been fully reviewed.
-
//!
-
use std::fmt::Write as _;
-
use std::io;
-
use std::ops::{Not, Range};
-
use std::path::PathBuf;
-

-
use radicle::cob::patch::Revision;
-
use radicle::cob::{CodeLocation, CodeRange};
-
use radicle::git;
-
use radicle::git::Oid;
-
use radicle::prelude::*;
-
use radicle::storage::git::Repository;
-
use radicle_surf::diff::*;
-

-
use radicle_cli::git::unified_diff::{self, FileHeader};
-
use radicle_cli::git::unified_diff::{Encode, HunkHeader};
-
use radicle_cli::terminal as term;
-

-
use crate::git::HunkDiff;
-

-
/// Queue of items (usually hunks) left to review.
-
#[derive(Clone, Default, Debug)]
-
pub struct Hunks {
-
    hunks: Vec<HunkDiff>,
-
}
-

-
impl Hunks {
-
    pub fn new(base: Diff) -> Self {
-
        let base_files = base.into_files();
-

-
        let mut queue = Self::default();
-
        for file in base_files {
-
            queue.add_file(file);
-
        }
-
        queue
-
    }
-

-
    /// Add a file to the queue.
-
    /// Mostly splits files into individual review items (eg. hunks) to review.
-
    fn add_file(&mut self, file: FileDiff) {
-
        let header = FileHeader::from(&file);
-

-
        match file {
-
            FileDiff::Moved(moved) => {
-
                self.add_item(HunkDiff::Moved { moved });
-
            }
-
            FileDiff::Copied(copied) => {
-
                self.add_item(HunkDiff::Copied {
-
                    copied: copied.clone(),
-
                });
-
            }
-
            FileDiff::Added(a) => {
-
                self.add_item(HunkDiff::Added {
-
                    path: a.path.clone(),
-
                    header: header.clone(),
-
                    new: a.new.clone(),
-
                    hunk: if let DiffContent::Plain {
-
                        hunks: Hunks(mut hs),
-
                        ..
-
                    } = a.diff.clone()
-
                    {
-
                        hs.pop()
-
                    } else {
-
                        None
-
                    },
-
                    _stats: a.diff.stats().cloned(),
-
                });
-
            }
-
            FileDiff::Deleted(d) => {
-
                self.add_item(HunkDiff::Deleted {
-
                    path: d.path.clone(),
-
                    header: header.clone(),
-
                    old: d.old.clone(),
-
                    hunk: if let DiffContent::Plain {
-
                        hunks: Hunks(mut hs),
-
                        ..
-
                    } = d.diff.clone()
-
                    {
-
                        hs.pop()
-
                    } else {
-
                        None
-
                    },
-
                    _stats: d.diff.stats().cloned(),
-
                });
-
            }
-
            FileDiff::Modified(m) => {
-
                if m.old.mode != m.new.mode {
-
                    self.add_item(HunkDiff::ModeChanged {
-
                        path: m.path.clone(),
-
                        header: header.clone(),
-
                        old: m.old.clone(),
-
                        new: m.new.clone(),
-
                    });
-
                }
-
                match m.diff.clone() {
-
                    DiffContent::Empty => {
-
                        // Likely a file mode change, which is handled above.
-
                    }
-
                    DiffContent::Binary => {
-
                        self.add_item(HunkDiff::Modified {
-
                            path: m.path.clone(),
-
                            header: header.clone(),
-
                            old: m.old.clone(),
-
                            new: m.new.clone(),
-
                            hunk: None,
-
                            _stats: m.diff.stats().cloned(),
-
                        });
-
                    }
-
                    DiffContent::Plain {
-
                        hunks: Hunks(hunks),
-
                        eof,
-
                        stats,
-
                    } => {
-
                        let base_hunks = hunks.clone();
-

-
                        for hunk in base_hunks {
-
                            self.add_item(HunkDiff::Modified {
-
                                path: m.path.clone(),
-
                                header: header.clone(),
-
                                old: m.old.clone(),
-
                                new: m.new.clone(),
-
                                hunk: Some(hunk),
-
                                _stats: Some(stats),
-
                            });
-
                        }
-
                        if let EofNewLine::OldMissing | EofNewLine::NewMissing = eof {
-
                            self.add_item(HunkDiff::EofChanged {
-
                                path: m.path.clone(),
-
                                header: header.clone(),
-
                                old: m.old.clone(),
-
                                new: m.new.clone(),
-
                                _eof: eof,
-
                            })
-
                        }
-
                    }
-
                }
-
            }
-
        }
-
    }
-

-
    fn add_item(&mut self, item: HunkDiff) {
-
        self.hunks.push(item);
-
    }
-
}
-

-
impl std::ops::Deref for Hunks {
-
    type Target = Vec<HunkDiff>;
-

-
    fn deref(&self) -> &Self::Target {
-
        &self.hunks
-
    }
-
}
-

-
impl std::ops::DerefMut for Hunks {
-
    fn deref_mut(&mut self) -> &mut Self::Target {
-
        &mut self.hunks
-
    }
-
}
-

-
pub struct DiffUtil<'a> {
-
    repo: &'a Repository,
-
}
-

-
impl<'a> DiffUtil<'a> {
-
    pub fn new(repo: &'a Repository) -> Self {
-
        Self { repo }
-
    }
-

-
    pub fn all_diffs(&self, revision: &Revision) -> anyhow::Result<Diff> {
-
        let repo = self.repo.raw();
-

-
        let base = repo.find_commit((*revision.base()).into())?.tree()?;
-
        let revision = {
-
            let commit = repo.find_commit(revision.head().into())?;
-
            commit.tree()?
-
        };
-

-
        let mut opts = git::raw::DiffOptions::new();
-
        opts.patience(true).minimal(true).context_lines(3_u32);
-

-
        let base_diff = self.diff(&base, &revision, repo, &mut opts)?;
-

-
        Ok(base_diff)
-
    }
-

-
    pub fn diff(
-
        &self,
-
        brain: &git::raw::Tree<'_>,
-
        tree: &git::raw::Tree<'_>,
-
        repo: &'a git::raw::Repository,
-
        opts: &mut git::raw::DiffOptions,
-
    ) -> Result<Diff, Error> {
-
        let mut find_opts = git::raw::DiffFindOptions::new();
-
        find_opts.exact_match_only(true);
-
        find_opts.all(true);
-
        find_opts.copies(false); // We don't support finding copies at the moment.
-

-
        let mut diff = repo.diff_tree_to_tree(Some(brain), Some(tree), Some(opts))?;
-
        diff.find_similar(Some(&mut find_opts))?;
-

-
        let diff = Diff::try_from(diff)?;
-

-
        Ok(diff)
-
    }
-
}
-

-
/// Builds a patch review interactively, across multiple files.
-
pub struct ReviewBuilder<'a> {
-
    /// Stored copy of repository.
-
    repo: &'a Repository,
-
}
-

-
impl<'a> ReviewBuilder<'a> {
-
    /// Create a new review builder.
-
    pub fn new(repo: &'a Repository) -> Self {
-
        Self { repo }
-
    }
-

-
    pub fn hunks(&self, revision: &Revision) -> anyhow::Result<Hunks> {
-
        let diff = DiffUtil::new(self.repo).all_diffs(revision)?;
-
        Ok(Hunks::new(diff))
-
    }
-
}
-

-
#[derive(Debug, PartialEq, Eq)]
-
pub struct ReviewComment {
-
    pub location: CodeLocation,
-
    pub body: String,
-
}
-

-
#[derive(thiserror::Error, Debug)]
-
pub enum Error {
-
    #[error(transparent)]
-
    Diff(#[from] unified_diff::Error),
-
    #[error(transparent)]
-
    Surf(#[from] radicle_surf::diff::git::error::Diff),
-
    #[error(transparent)]
-
    Io(#[from] io::Error),
-
    #[error(transparent)]
-
    Format(#[from] std::fmt::Error),
-
    #[error(transparent)]
-
    Git(#[from] git::raw::Error),
-
}
-

-
#[derive(Debug)]
-
pub struct CommentBuilder {
-
    commit: Oid,
-
    path: PathBuf,
-
    comments: Vec<ReviewComment>,
-
}
-

-
impl CommentBuilder {
-
    pub fn new(commit: Oid, path: PathBuf) -> Self {
-
        Self {
-
            commit,
-
            path,
-
            comments: Vec::new(),
-
        }
-
    }
-

-
    pub fn edit(mut self, hunk: &Hunk<Modification>) -> Result<Vec<ReviewComment>, Error> {
-
        let mut input = String::new();
-
        for line in hunk.to_unified_string()?.lines() {
-
            writeln!(&mut input, "> {line}")?;
-
        }
-

-
        let output = term::Editor::comment()
-
            .extension("diff")
-
            .initial(input)?
-
            .edit()?;
-

-
        if let Some(output) = output {
-
            let header = HunkHeader::try_from(hunk)?;
-
            self.add_hunk(header, &output);
-
        }
-
        Ok(self.comments())
-
    }
-

-
    pub fn add_hunk(&mut self, hunk: HunkHeader, input: &str) -> &mut Self {
-
        let lines = input.trim().lines().map(|l| l.trim());
-
        let (mut old_line, mut new_line) = (hunk.old_line_no as usize, hunk.new_line_no as usize);
-
        let (mut old_start, mut new_start) = (old_line, new_line);
-
        let mut comment = String::new();
-

-
        for line in lines {
-
            if line.starts_with('>') {
-
                if !comment.is_empty() {
-
                    self.add_comment(
-
                        &hunk,
-
                        &comment,
-
                        old_start..old_line - 1,
-
                        new_start..new_line - 1,
-
                    );
-

-
                    old_start = old_line - 1;
-
                    new_start = new_line - 1;
-

-
                    comment.clear();
-
                }
-
                match line.trim_start_matches('>').trim_start().chars().next() {
-
                    Some('-') => old_line += 1,
-
                    Some('+') => new_line += 1,
-
                    _ => {
-
                        old_line += 1;
-
                        new_line += 1;
-
                    }
-
                }
-
            } else {
-
                comment.push_str(line);
-
                comment.push('\n');
-
            }
-
        }
-
        if !comment.is_empty() {
-
            self.add_comment(
-
                &hunk,
-
                &comment,
-
                old_start..old_line - 1,
-
                new_start..new_line - 1,
-
            );
-
        }
-
        self
-
    }
-

-
    fn add_comment(
-
        &mut self,
-
        hunk: &HunkHeader,
-
        comment: &str,
-
        mut old_range: Range<usize>,
-
        mut new_range: Range<usize>,
-
    ) {
-
        // Empty lines between quoted text can generate empty comments
-
        // that should be filtered out.
-
        if comment.trim().is_empty() {
-
            return;
-
        }
-
        // Top-level comment, it should apply to the whole hunk.
-
        if old_range.is_empty() && new_range.is_empty() {
-
            old_range = hunk.old_line_no as usize..(hunk.old_line_no + hunk.old_size + 1) as usize;
-
            new_range = hunk.new_line_no as usize..(hunk.new_line_no + hunk.new_size + 1) as usize;
-
        }
-
        let old_range = old_range
-
            .is_empty()
-
            .not()
-
            .then_some(old_range)
-
            .map(|range| CodeRange::Lines { range });
-
        let new_range = (new_range)
-
            .is_empty()
-
            .not()
-
            .then_some(new_range)
-
            .map(|range| CodeRange::Lines { range });
-

-
        self.comments.push(ReviewComment {
-
            location: CodeLocation {
-
                commit: self.commit,
-
                path: self.path.clone(),
-
                old: old_range,
-
                new: new_range,
-
            },
-
            body: comment.trim().to_owned(),
-
        });
-
    }
-

-
    fn comments(self) -> Vec<ReviewComment> {
-
        self.comments
-
    }
-
}
-
#[cfg(test)]
-
mod tests {
-
    use super::*;
-
    use std::str::FromStr;
-

-
    #[test]
-
    fn test_review_comments_basic() {
-
        let input = r#"
-
> @@ -2559,18 +2560,18 @@ where
-
>                  // Only consider onion addresses if configured.
-
>                  AddressType::Onion => self.config.onion.is_some(),
-
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
-
> -            })
-
> -            .take(wanted)
-
> -            .collect::<Vec<_>>(); // # -2564
-

-
Comment #1.
-

-
> +            });
-
>
-
> -        if available.len() < target {
-
> -            log::warn!( # -2567
-
> +        // Peers we are going to attempt connections to.
-
> +        let connect = available.take(wanted).collect::<Vec<_>>();
-

-
Comment #2.
-

-
> +        if connect.len() < wanted {
-
> +            log::debug!(
-
>                  target: "service",
-
> -                "Not enough available peers to connect to (available={}, target={target})",
-
> -                available.len()
-

-
Comment #3.
-

-
> +                "Not enough available peers to connect to (available={}, wanted={wanted})",
-

-
Comment #4.
-

-
> +                connect.len()
-
>              );
-
>          }
-
> -        for (id, ka) in available {
-
> +        for (id, ka) in connect {
-
>              self.connect(id, ka.addr.clone());
-
>          }
-
>     }
-

-
Comment #5.
-

-
"#;
-

-
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
-
        let path = PathBuf::from_str("main.rs").unwrap();
-
        let expected = &[
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
-
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
-
                },
-
                body: "Comment #1.".to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
-
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
-
                },
-
                body: "Comment #2.".to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2568..2571 }),
-
                    new: Some(CodeRange::Lines { range: 2567..2570 }),
-
                },
-
                body: "Comment #3.".to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: None,
-
                    new: Some(CodeRange::Lines { range: 2570..2571 }),
-
                },
-
                body: "Comment #4.".to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2571..2577 }),
-
                    new: Some(CodeRange::Lines { range: 2571..2578 }),
-
                },
-
                body: "Comment #5.".to_owned(),
-
            }),
-
        ];
-

-
        let mut builder = CommentBuilder::new(commit, path.clone());
-
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 18,
-
                new_line_no: 2560,
-
                new_size: 18,
-
                text: vec![],
-
            },
-
            input,
-
        );
-
        let actual = builder.comments();
-

-
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
-

-
        for (left, right) in actual.iter().zip(expected) {
-
            assert_eq!(left, right);
-
        }
-
    }
-

-
    #[test]
-
    fn test_review_comments_multiline() {
-
        let input = r#"
-
> @@ -2559,9 +2560,7 @@ where
-
>                  // Only consider onion addresses if configured.
-
>                  AddressType::Onion => self.config.onion.is_some(),
-
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
-
> -            })
-
> -            .take(wanted)
-
> -            .collect::<Vec<_>>(); // # -2564
-

-
Blah blah blah blah blah blah blah.
-
Blah blah blah.
-

-
Blaah blaah blaah blaah blaah blaah blaah.
-
blaah blaah blaah.
-

-
Blaaah blaaah blaaah.
-

-
> +            });
-
>
-
> -        if available.len() < target {
-
> -            log::warn!( # -2567
-
> +        // Peers we are going to attempt connections to.
-
> +        let connect = available.take(wanted).collect::<Vec<_>>();
-

-
Woof woof.
-
Woof.
-
Woof.
-

-
Woof.
-

-
"#;
-

-
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
-
        let path = PathBuf::from_str("main.rs").unwrap();
-
        let expected = &[
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
-
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
-
                },
-
                body: r#"
-
Blah blah blah blah blah blah blah.
-
Blah blah blah.
-

-
Blaah blaah blaah blaah blaah blaah blaah.
-
blaah blaah blaah.
-

-
Blaaah blaaah blaaah.
-
"#
-
                .trim()
-
                .to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
-
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
-
                },
-
                body: r#"
-
Woof woof.
-
Woof.
-
Woof.
-

-
Woof.
-
"#
-
                .trim()
-
                .to_owned(),
-
            }),
-
        ];
-

-
        let mut builder = CommentBuilder::new(commit, path.clone());
-
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 9,
-
                new_line_no: 2560,
-
                new_size: 7,
-
                text: vec![],
-
            },
-
            input,
-
        );
-
        let actual = builder.comments();
-

-
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
-

-
        for (left, right) in actual.iter().zip(expected) {
-
            assert_eq!(left, right);
-
        }
-
    }
-

-
    #[test]
-
    fn test_review_comments_before() {
-
        let input = r#"
-
This is a top-level comment.
-

-
> @@ -2559,9 +2560,7 @@ where
-
>                  // Only consider onion addresses if configured.
-
>                  AddressType::Onion => self.config.onion.is_some(),
-
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
-
> -            })
-
> -            .take(wanted)
-
> -            .collect::<Vec<_>>(); // # -2564
-
> +            });
-
>
-
> -        if available.len() < target {
-
> -            log::warn!( # -2567
-
> +        // Peers we are going to attempt connections to.
-
> +        let connect = available.take(wanted).collect::<Vec<_>>();
-
"#;
-

-
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
-
        let path = PathBuf::from_str("main.rs").unwrap();
-
        let expected = &[(ReviewComment {
-
            location: CodeLocation {
-
                commit,
-
                path: path.clone(),
-
                old: Some(CodeRange::Lines { range: 2559..2569 }),
-
                new: Some(CodeRange::Lines { range: 2560..2568 }),
-
            },
-
            body: "This is a top-level comment.".to_owned(),
-
        })];
-

-
        let mut builder = CommentBuilder::new(commit, path.clone());
-
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 9,
-
                new_line_no: 2560,
-
                new_size: 7,
-
                text: vec![],
-
            },
-
            input,
-
        );
-
        let actual = builder.comments();
-

-
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
-

-
        for (left, right) in actual.iter().zip(expected) {
-
            assert_eq!(left, right);
-
        }
-
    }
-

-
    #[test]
-
    fn test_review_comments_split_hunk() {
-
        let input = r#"
-
> @@ -2559,6 +2560,4 @@ where
-
>                  // Only consider onion addresses if configured.
-
>                  AddressType::Onion => self.config.onion.is_some(),
-
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
-
> -            })
-
> -            .take(wanted)
-

-
> -            .collect::<Vec<_>>();
-
> +            });
-

-
Comment on a split hunk.
-
"#;
-

-
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
-
        let path = PathBuf::from_str("main.rs").unwrap();
-
        let expected = &[(ReviewComment {
-
            location: CodeLocation {
-
                commit,
-
                path: path.clone(),
-
                old: Some(CodeRange::Lines { range: 2564..2565 }),
-
                new: Some(CodeRange::Lines { range: 2563..2564 }),
-
            },
-
            body: "Comment on a split hunk.".to_owned(),
-
        })];
-

-
        let mut builder = CommentBuilder::new(commit, path.clone());
-
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 6,
-
                new_line_no: 2560,
-
                new_size: 4,
-
                text: vec![],
-
            },
-
            input,
-
        );
-
        let actual = builder.comments();
-

-
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
-

-
        for (left, right) in actual.iter().zip(expected) {
-
            assert_eq!(left, right);
-
        }
-
    }
-
}
modified bin/main.rs
@@ -1,5 +1,5 @@
+
mod apps;
mod cob;
-
mod commands;
mod git;
mod log;
mod settings;
@@ -20,7 +20,7 @@ use radicle::version::Version;

use radicle_cli::terminal as cli_term;

-
use commands::*;
+
use apps::*;
use terminal as term;

use crate::terminal::ForwardError;