Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
patch-select: Add support for patch filter option
Erik Kundt committed 2 years ago
commit 8909cb668f8f66245cc4d30fed59c3aa2f7e3331
parent 5e9e6140edc8cce6c35830158fb5cf75b87b9b92
9 files changed +177 -59
modified bin/commands/patch.rs
@@ -10,7 +10,8 @@ use std::ffi::OsString;
use anyhow::anyhow;

use radicle_tui as tui;
-
use tui::cob::patch;
+

+
use tui::cob::patch::{self, State};
use tui::{context, log, Window};

use crate::terminal;
@@ -24,16 +25,24 @@ pub const HELP: Help = Help {
    usage: r#"
Usage

-
    rad-tui patch [<option>...]
-
    rad-tui patch select [--operation | --id] [<option>...]
+
    rad-tui patch select [<option>...]

Select options

-
    --operation         Select patch id and operation (default)
-
    --id                Select patch id only
+
    --mode <MODE>           Set selection mode; see MODE below (default: operation)
+
    --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)
+

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

-

Other options

    --json              Output is JSON (default: false)
@@ -57,25 +66,18 @@ pub enum OperationName {

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SelectOptions {
-
    subject: select::Subject,
+
    mode: select::Mode,
    filter: patch::Filter,
}

-
impl SelectOptions {
-
    pub fn new(subject: select::Subject, filter: patch::Filter) -> Self {
-
        Self { subject, filter }
-
    }
-
}
-

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

        let mut parser = lexopt::Parser::from_args(args);
        let mut op: Option<OperationName> = None;
-
        let filter = patch::Filter::default();
        let mut json = false;
-
        let mut select_opts: Option<SelectOptions> = None;
+
        let mut select_opts = SelectOptions::default();

        #[allow(clippy::never_loop)]
        while let Some(arg) = parser.next()? {
@@ -87,21 +89,39 @@ impl Args for Options {
                    json = true;
                }

-
                // Select options.
-
                Long("operation") | Short('o') if op == Some(OperationName::Select) => {
-
                    if select_opts.is_some() {
-
                        anyhow::bail!("select option already given")
-
                    }
-
                    select_opts = Some(SelectOptions::new(
-
                        select::Subject::Operation,
-
                        filter.clone(),
-
                    ));
+
                // select options.
+
                Long("mode") | Short('m') if op == Some(OperationName::Select) => {
+
                    let val = parser.value()?;
+
                    let val = val.to_str().unwrap_or_default();
+

+
                    select_opts.mode = match val {
+
                        "operation" => select::Mode::Operation,
+
                        "id" => select::Mode::Id,
+
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
+
                    };
+
                }
+
                Long("all") if op == Some(OperationName::Select) => {
+
                    select_opts.filter = select_opts.filter.with_state(None);
+
                }
+
                Long("draft") if op == Some(OperationName::Select) => {
+
                    select_opts.filter = select_opts.filter.with_state(Some(State::Draft));
+
                }
+
                Long("archived") if op == Some(OperationName::Select) => {
+
                    select_opts.filter = select_opts.filter.with_state(Some(State::Archived));
+
                }
+
                Long("merged") if op == Some(OperationName::Select) => {
+
                    select_opts.filter = select_opts.filter.with_state(Some(State::Merged));
+
                }
+
                Long("open") if op == Some(OperationName::Select) => {
+
                    select_opts.filter = select_opts.filter.with_state(Some(State::Open));
+
                }
+
                Long("authored") if op == Some(OperationName::Select) => {
+
                    select_opts.filter = select_opts.filter.with_authored(true);
                }
-
                Long("id") | Short('i') if op == Some(OperationName::Select) => {
-
                    if select_opts.is_some() {
-
                        anyhow::bail!("select option already given")
-
                    }
-
                    select_opts = Some(SelectOptions::new(select::Subject::Id, filter.clone()));
+
                Long("author") if op == Some(OperationName::Select) => {
+
                    select_opts.filter = select_opts
+
                        .filter
+
                        .with_author(terminal::args::did(&parser.value()?)?);
                }

                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
@@ -113,9 +133,7 @@ impl Args for Options {
        }

        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
-
            OperationName::Select => Operation::Select {
-
                opts: select_opts.unwrap_or_default(),
-
            },
+
            OperationName::Select => Operation::Select { opts: select_opts },
        };
        Ok((Options { op, json }, vec![]))
    }
@@ -132,7 +150,7 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>

            log::enable(context.profile(), "patch", "select")?;

-
            let mut app = select::App::new(context, opts.subject.clone(), opts.filter.clone());
+
            let mut app = select::App::new(context, opts.mode.clone(), opts.filter.clone());
            let output = Window::default().run(&mut app, 1000 / FPS)?;

            let output = if options.json {
@@ -141,9 +159,9 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>
                    .unwrap_or_default()
            } else {
                match options.op {
-
                    Operation::Select { ref opts } => match &opts.subject {
-
                        select::Subject::Id => output.map(|o| format!("{}", o)).unwrap_or_default(),
-
                        select::Subject::Operation => output
+
                    Operation::Select { ref opts } => match &opts.mode {
+
                        select::Mode::Id => output.map(|o| format!("{}", o)).unwrap_or_default(),
+
                        select::Mode::Operation => output
                            .map(|o| format!("rad patch {}", o))
                            .unwrap_or_default(),
                    },
modified bin/commands/patch/common/ui.rs
@@ -56,7 +56,7 @@ impl PatchBrowser {
            .as_ref()
            .unwrap()
            .iter()
-
            .filter(|(_, patch)| filter.matches(patch));
+
            .filter(|(_, patch)| filter.matches(context.profile(), patch));

        let mut items = vec![];
        for (id, patch) in patches {
@@ -104,7 +104,12 @@ impl WidgetComponent for PatchBrowser {
    }
}

-
pub fn browse_context(context: &Context, _theme: &Theme, progress: Progress) -> Widget<ContextBar> {
+
pub fn browse_context(
+
    context: &Context,
+
    _theme: &Theme,
+
    filter: Filter,
+
    progress: Progress,
+
) -> Widget<ContextBar> {
    use radicle::cob::patch::State;

    let mut draft = 0;
@@ -112,7 +117,13 @@ pub fn browse_context(context: &Context, _theme: &Theme, progress: Progress) ->
    let mut archived = 0;
    let mut merged = 0;

-
    let patches = context.patches().as_ref().unwrap();
+
    let patches = context
+
        .patches()
+
        .as_ref()
+
        .unwrap()
+
        .iter()
+
        .filter(|(_, patch)| filter.matches(context.profile(), patch));
+

    for (_, patch) in patches {
        match patch.state() {
            State::Draft => draft += 1,
@@ -125,8 +136,8 @@ pub fn browse_context(context: &Context, _theme: &Theme, progress: Progress) ->
        }
    }

-
    let context = label::default("");
-
    let divider = label::default(" | ");
+
    let context = label::reversable("/").style(style::magenta_reversed());
+
    let filter = label::default(&filter.to_string()).style(style::magenta_dim());

    let draft_n = label::default(&format!("{draft}")).style(style::gray_dim());
    let draft = label::default(" Draft");
@@ -141,11 +152,13 @@ pub fn browse_context(context: &Context, _theme: &Theme, progress: Progress) ->
    let merged = label::default(" Merged ");

    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
+

    let spacer = label::default("");
+
    let divider = label::default(" | ");

    let context_bar = ContextBar::new(
        label::group(&[context]),
-
        label::group(&[spacer.clone()]),
+
        label::group(&[filter]),
        label::group(&[spacer]),
        label::group(&[
            draft_n,
modified bin/commands/patch/select.rs
@@ -57,7 +57,7 @@ impl Serialize for PatchId {
///
/// Depends on CLI arguments given by the user.
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Subject {
+
pub enum Mode {
    #[default]
    Operation,
    Id,
@@ -152,7 +152,7 @@ pub struct App {
    pages: PageStack<Cid, Message>,
    theme: Theme,
    quit: bool,
-
    subject: Subject,
+
    mode: Mode,
    filter: Filter,
    output: Option<Output>,
}
@@ -160,13 +160,13 @@ pub struct App {
/// Creates a new application using a tui-realm-application, mounts all
/// components and sets focus to a default one.
impl App {
-
    pub fn new(context: Context, subject: Subject, filter: Filter) -> Self {
+
    pub fn new(context: Context, mode: Mode, filter: Filter) -> Self {
        Self {
            context,
            pages: PageStack::default(),
            theme: Theme::default(),
            quit: false,
-
            subject,
+
            mode,
            filter,
            output: None,
        }
@@ -177,7 +177,7 @@ impl App {
        app: &mut Application<Cid, Message, NoUserEvent>,
        theme: &Theme,
    ) -> Result<()> {
-
        let home = Box::new(ListView::new(self.subject.clone(), self.filter.clone()));
+
        let home = Box::new(ListView::new(self.mode.clone(), self.filter.clone()));
        self.pages.push(home, app, &self.context, theme)?;

        Ok(())
modified bin/commands/patch/select/page.rs
@@ -15,20 +15,20 @@ use tui::ui::{layout, subscription};
use tui::ViewPage;

use super::super::common;
-
use super::{ui, Application, Cid, ListCid, Message, Subject};
+
use super::{ui, Application, Cid, ListCid, Message, Mode};

///
/// Home
///
pub struct ListView {
    active_component: ListCid,
-
    subject: Subject,
+
    subject: Mode,
    filter: Filter,
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
}

impl ListView {
-
    pub fn new(subject: Subject, filter: Filter) -> Self {
+
    pub fn new(subject: Mode, filter: Filter) -> Self {
        Self {
            active_component: ListCid::PatchBrowser,
            subject,
@@ -50,7 +50,7 @@ impl ListView {
            }
            _ => Progress::None,
        };
-
        let context = common::ui::browse_context(context, theme, progress);
+
        let context = common::ui::browse_context(context, theme, self.filter.clone(), progress);

        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;

@@ -87,14 +87,14 @@ impl ViewPage<Cid, Message> for ListView {
        app.remount(Cid::List(ListCid::Header), header, vec![])?;

        match self.subject {
-
            Subject::Id => {
+
            Mode::Id => {
                let patch_browser =
                    ui::id_select(theme, context, self.filter.clone(), None).to_boxed();
                self.shortcuts = patch_browser.as_ref().shortcuts();

                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
            }
-
            Subject::Operation => {
+
            Mode::Operation => {
                let patch_browser =
                    ui::operation_select(theme, context, self.filter.clone(), None).to_boxed();
                self.shortcuts = patch_browser.as_ref().shortcuts();
modified src/cob.rs
@@ -5,6 +5,7 @@ use anyhow::Result;
use radicle::cob::Label;
use radicle::prelude::Did;

+
pub mod format;
pub mod issue;
pub mod patch;

added src/cob/format.rs
@@ -0,0 +1,7 @@
+
use radicle::identity::Did;
+

+
/// Format a DID.
+
pub fn did(did: &Did) -> String {
+
    let nid = did.as_key().to_human();
+
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
+
}
modified src/cob/patch.rs
@@ -1,8 +1,13 @@
+
use std::fmt::Display;
+

use anyhow::Result;

use radicle::cob::patch::{Patch, PatchId, Patches};
use radicle::identity::Did;
use radicle::storage::git::Repository;
+
use radicle::{patch, Profile};
+

+
use super::format;

#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub enum State {
@@ -13,6 +18,18 @@ pub enum State {
    Archived,
}

+
impl Display for State {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        let state = match self {
+
            State::Draft => "draft",
+
            State::Open => "open",
+
            State::Merged => "merged",
+
            State::Archived => "archived",
+
        };
+
        f.write_str(state)
+
    }
+
}
+

#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub struct Filter {
    state: Option<State>,
@@ -21,8 +38,8 @@ pub struct Filter {
}

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

@@ -36,8 +53,61 @@ impl Filter {
        self
    }

-
    pub fn matches(&self, _patch: &Patch) -> bool {
-
        true
+
    pub fn matches(&self, profile: &Profile, patch: &Patch) -> bool {
+
        let matches_state = match self.state {
+
            Some(State::Draft) => matches!(patch.state(), patch::State::Draft),
+
            Some(State::Open) => matches!(patch.state(), patch::State::Open { .. }),
+
            Some(State::Merged) => matches!(patch.state(), patch::State::Merged { .. }),
+
            Some(State::Archived) => matches!(patch.state(), patch::State::Archived),
+
            None => true,
+
        };
+

+
        let matches_authored = self
+
            .authored
+
            .then(|| *patch.author().id() == profile.did())
+
            .unwrap_or(true);
+

+
        let matches_authors = (!self.authors.is_empty())
+
            .then(|| {
+
                self.authors
+
                    .iter()
+
                    .any(|other| *patch.author().id() == *other)
+
            })
+
            .unwrap_or(true);
+

+
        matches_state && matches_authored && matches_authors
+
    }
+
}
+

+
impl ToString for Filter {
+
    fn to_string(&self) -> String {
+
        let mut filter = String::new();
+
        filter.push(' ');
+

+
        if let Some(state) = &self.state {
+
            filter.push_str(&format!("is:{}", state));
+
            filter.push(' ');
+
        }
+
        if self.authored {
+
            filter.push_str("is:authored");
+
            filter.push(' ');
+
        }
+
        if !self.authors.is_empty() {
+
            filter.push_str("authors:");
+
            filter.push('[');
+

+
            let mut authors = self.authors.iter().peekable();
+
            while let Some(author) = authors.next() {
+
                filter.push_str(&format::did(author));
+

+
                if authors.peek().is_some() {
+
                    filter.push(',');
+
                }
+
            }
+
            filter.push(']');
+
        }
+

+
        filter
    }
}

modified src/context.rs
@@ -6,7 +6,7 @@ use radicle::cob::issue::{Issue, IssueId};
use radicle::cob::patch::{Patch, PatchId};
use radicle::crypto::ssh::keystore::{Keystore, MemorySigner};
use radicle::crypto::Signer;
-
use radicle::prelude::{Id, Project};
+
use radicle::identity::{Id, Project};
use radicle::profile::env::RAD_PASSPHRASE;
use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage};
modified src/ui/theme.rs
@@ -66,6 +66,14 @@ pub mod style {
        Style::default().fg(Color::Yellow)
    }

+
    pub fn yellow_dim() -> Style {
+
        yellow().add_modifier(TextModifiers::DIM)
+
    }
+

+
    pub fn yellow_dim_reversed() -> Style {
+
        yellow_dim().add_modifier(TextModifiers::REVERSED)
+
    }
+

    pub fn blue() -> Style {
        Style::default().fg(Color::Blue)
    }
@@ -108,7 +116,8 @@ pub mod style {

    pub fn default_reversed() -> Style {
        Style::default()
-
            .add_modifier(TextModifiers::DIM)
+
            .fg(Color::DarkGray)
+
            // .add_modifier(TextModifiers::DIM)
            .add_modifier(TextModifiers::REVERSED)
    }