Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
bin/ui/items: Improve notification filter
Erik Kundt committed 6 months ago
commit 4ec2cd1ee338b76e4d239ade31894aa9a86d6ffd
parent ee20b956ad6fd11194160bcebb2b640134fe7607
2 files changed +281 -95
modified bin/commands/inbox/list/ui.rs
@@ -291,10 +291,22 @@ fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
        span::default(&props.notifications.len().to_string()).dim(),
    ]);

-
    match NotificationItemFilter::from_str(&props.search)
-
        .unwrap_or_default()
-
        .state()
-
    {
+
    let state = match NotificationItemFilter::from_str(&props.search).unwrap_or_default() {
+
        NotificationItemFilter::State(state) => Some(state),
+
        NotificationItemFilter::And(filters) => filters
+
            .into_iter()
+
            .map(|f| match f {
+
                NotificationItemFilter::State(state) => Some(state),
+
                _ => None,
+
            })
+
            .flatten()
+
            .collect::<Vec<_>>()
+
            .first()
+
            .cloned(),
+
        _ => None,
+
    };
+

+
    match state {
        Some(state) => {
            let block = match state {
                NotificationState::Seen => seen,
modified bin/ui/items.rs
@@ -4,9 +4,12 @@ use std::fmt::Debug;
use std::ops::Range;
use std::str::FromStr;

-
use nom::bytes::complete::{tag, take};
-
use nom::multi::separated_list0;
-
use nom::sequence::{delimited, preceded};
+
use nom::branch::alt;
+
use nom::bytes::complete::{tag, tag_no_case, take, take_while1};
+
use nom::character::complete::{char, multispace0};
+
use nom::combinator::{map, value};
+
use nom::multi::{many0, separated_list0, separated_list1};
+
use nom::sequence::{delimited, preceded, tuple};
use nom::{IResult, Parser};

use ansi_to_tui::IntoText;
@@ -32,6 +35,7 @@ use radicle_cli::git::unified_diff::{Decode, HunkHeader};
use radicle_cli::terminal;
use radicle_cli::terminal::highlight::Highlighter;

+
use radicle_tui::ui::theme::style;
use ratatui::prelude::*;
use ratatui::style::{Color, Style, Stylize};
use ratatui::widgets::Cell;
@@ -40,7 +44,6 @@ use tui_tree_widget::TreeItem;

use radicle_tui as tui;

-
use tui::ui::theme::style;
use tui::ui::utils::{LineMerger, MergeLocation};
use tui::ui::{span, Column};
use tui::ui::{ToRow, ToTree};
@@ -340,6 +343,7 @@ pub enum NotificationType {
    Patch,
    Issue,
    Branch,
+
    Unknown,
}

#[derive(Clone, Debug, Eq, PartialEq)]
@@ -348,20 +352,36 @@ pub enum NotificationState {
    Unseen,
}

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct NotificationItemFilter {
-
    state: Option<NotificationState>,
-
    type_name: Option<NotificationType>,
-
    authors: Vec<Did>,
-
    search: Option<String>,
+
#[derive(Debug, Clone, PartialEq)]
+
pub enum NotificationItemFilter {
+
    State(NotificationState),
+
    Type(NotificationTypeFilter),
+
    Author(NotificationAuthorFilter),
+
    Search(String),
+
    And(Vec<NotificationItemFilter>),
}

-
impl NotificationItemFilter {
-
    pub fn state(&self) -> Option<NotificationState> {
-
        self.state.clone()
+
impl Default for NotificationItemFilter {
+
    fn default() -> Self {
+
        Self::Type(NotificationTypeFilter::Or(vec![
+
            NotificationType::Issue,
+
            NotificationType::Patch,
+
        ]))
    }
}

+
#[derive(Debug, Clone, PartialEq)]
+
pub enum NotificationTypeFilter {
+
    Single(NotificationType),
+
    Or(Vec<NotificationType>),
+
}
+

+
#[derive(Debug, Clone, PartialEq)]
+
pub enum NotificationAuthorFilter {
+
    Single(Did),
+
    Or(Vec<Did>),
+
}
+

impl Filter<NotificationItem> for NotificationItemFilter {
    fn matches(&self, notif: &NotificationItem) -> bool {
        use fuzzy_matcher::skim::SkimMatcherV2;
@@ -369,43 +389,43 @@ impl Filter<NotificationItem> for NotificationItemFilter {

        let matcher = SkimMatcherV2::default();

-
        let matches_state = match self.state {
-
            Some(NotificationState::Seen) => notif.seen,
-
            Some(NotificationState::Unseen) => !notif.seen,
-
            None => true,
-
        };
-

-
        let matches_type = match self.type_name {
-
            Some(NotificationType::Patch) => matches!(&notif.kind, NotificationKindItem::Cob {
-
                type_name,
-
                summary: _,
-
                status: _,
-
                id: _,
-
            } if type_name == "patch"),
-
            Some(NotificationType::Issue) => matches!(&notif.kind, NotificationKindItem::Cob {
-
                    type_name,
-
                    summary: _,
-
                    status: _,
-
                    id: _,
-
                } if type_name == "issue"),
-
            Some(NotificationType::Branch) => {
+
        let match_type = |type_name: &NotificationType| match type_name {
+
            NotificationType::Issue => matches!(&notif.kind, NotificationKindItem::Cob {
+
                        type_name,
+
                        summary: _,
+
                        status: _,
+
                        id: _,
+
                    } if type_name == "issue"),
+
            NotificationType::Patch => matches!(&notif.kind, NotificationKindItem::Cob {
+
                        type_name,
+
                        summary: _,
+
                        status: _,
+
                        id: _,
+
                    } if type_name == "patch"),
+
            NotificationType::Branch => {
                matches!(notif.kind, NotificationKindItem::Branch { .. })
            }
-
            None => true,
-
        };
-

-
        let matches_authors = if !self.authors.is_empty() {
-
            {
-
                self.authors
-
                    .iter()
-
                    .any(|other| notif.author.nid == Some(**other))
+
            NotificationType::Unknown => {
+
                matches!(notif.kind, NotificationKindItem::Unknown { .. })
            }
-
        } else {
-
            true
        };

-
        let matches_search = match &self.search {
-
            Some(search) => {
+
        match self {
+
            NotificationItemFilter::State(state) => match state {
+
                NotificationState::Seen => notif.seen,
+
                NotificationState::Unseen => !notif.seen,
+
            },
+
            NotificationItemFilter::Type(type_filter) => match type_filter {
+
                NotificationTypeFilter::Single(type_name) => match_type(type_name),
+
                NotificationTypeFilter::Or(types) => types.iter().any(|other| match_type(other)),
+
            },
+
            NotificationItemFilter::Author(author_filter) => match author_filter {
+
                NotificationAuthorFilter::Single(author) => notif.author.nid == Some(**author),
+
                NotificationAuthorFilter::Or(authors) => authors
+
                    .iter()
+
                    .any(|other| notif.author.nid == Some(**other)),
+
            },
+
            NotificationItemFilter::Search(search) => {
                let summary = match &notif.kind {
                    NotificationKindItem::Cob {
                        type_name: _,
@@ -426,59 +446,171 @@ impl Filter<NotificationItem> for NotificationItemFilter {
                    _ => false,
                }
            }
-
            None => true,
-
        };
-

-
        matches_state && matches_type && matches_authors && matches_search
+
            NotificationItemFilter::And(filters) => filters.iter().all(|f| f.matches(notif)),
+
        }
    }
}

impl FromStr for NotificationItemFilter {
    type Err = anyhow::Error;

-
    fn from_str(value: &str) -> Result<Self, Self::Err> {
-
        let mut state = None;
-
        let mut type_name = None;
-
        let mut search = String::new();
-
        let mut authors = vec![];
+
    fn from_str(filter_exp: &str) -> Result<Self, Self::Err> {
+
        fn parse_did(input: &str) -> IResult<&str, Did> {
+
            match Did::from_str(input) {
+
                Ok(did) => IResult::Ok(("", did)),
+
                Err(_) => IResult::Err(nom::Err::Error(nom::error::Error::new(
+
                    input,
+
                    nom::error::ErrorKind::Verify,
+
                ))),
+
            }
+
        }

-
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("authors:"),
+
        fn parse_state(input: &str) -> IResult<&str, NotificationState> {
+
            alt((
+
                value(NotificationState::Seen, tag_no_case("seen")),
+
                value(NotificationState::Unseen, tag_no_case("unseen")),
+
            ))(input)
+
        }
+

+
        fn parse_state_filter(input: &str) -> IResult<&str, NotificationItemFilter> {
+
            map(
+
                preceded(
+
                    tuple((
+
                        tag_no_case("state"),
+
                        multispace0,
+
                        tag_no_case("="),
+
                        multispace0,
+
                    )),
+
                    parse_state,
+
                ),
+
                NotificationItemFilter::State,
+
            )(input)
+
        }
+

+
        fn parse_type(input: &str) -> IResult<&str, NotificationType> {
+
            alt((
+
                value(NotificationType::Patch, tag_no_case("patch")),
+
                value(NotificationType::Issue, tag_no_case("issue")),
+
                value(NotificationType::Branch, tag_no_case("branch")),
+
                value(NotificationType::Unknown, tag_no_case("unknown")),
+
            ))(input)
+
        }
+

+
        fn parse_type_single(input: &str) -> IResult<&str, NotificationTypeFilter> {
+
            map(parse_type, NotificationTypeFilter::Single)(input)
+
        }
+

+
        fn parse_type_or(input: &str) -> IResult<&str, NotificationTypeFilter> {
+
            map(
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
+
                    tuple((multispace0, char('('), multispace0)),
+
                    separated_list1(
+
                        delimited(multispace0, tag_no_case("or"), multispace0),
+
                        parse_type,
+
                    ),
+
                    tuple((multispace0, char(')'), multispace0)),
                ),
+
                NotificationTypeFilter::Or,
            )(input)
-
        };
+
        }

-
        let parts = value.split(' ');
-
        for part in parts {
-
            match part {
-
                "is:seen" => state = Some(NotificationState::Seen),
-
                "is:unseen" => state = Some(NotificationState::Unseen),
-
                "is:patch" => type_name = Some(NotificationType::Patch),
-
                "is:issue" => type_name = Some(NotificationType::Issue),
-
                "is:branch" => type_name = Some(NotificationType::Branch),
-
                other => {
-
                    if let Ok((_, dids)) = authors_parser.parse(other) {
-
                        for did in dids {
-
                            authors.push(Did::from_str(did)?);
-
                        }
+
        fn parse_type_filter(input: &str) -> IResult<&str, NotificationItemFilter> {
+
            map(
+
                preceded(
+
                    tuple((
+
                        tag_no_case("type"),
+
                        multispace0,
+
                        tag_no_case("="),
+
                        multispace0,
+
                    )),
+
                    alt((parse_type_or, parse_type_single)),
+
                ),
+
                NotificationItemFilter::Type,
+
            )(input)
+
        }
+

+
        fn parse_author_single(input: &str) -> IResult<&str, NotificationAuthorFilter> {
+
            map(parse_did, NotificationAuthorFilter::Single)(input)
+
        }
+

+
        fn parse_author_or(input: &str) -> IResult<&str, NotificationAuthorFilter> {
+
            map(
+
                delimited(
+
                    tuple((multispace0, char('('), multispace0)),
+
                    separated_list1(
+
                        delimited(multispace0, tag_no_case("or"), multispace0),
+
                        take(56_usize),
+
                    ),
+
                    tuple((multispace0, char(')'), multispace0)),
+
                ),
+
                |dids: Vec<&str>| {
+
                    NotificationAuthorFilter::Or(
+
                        dids.iter()
+
                            .filter_map(|did| Did::from_str(did).ok())
+
                            .collect::<Vec<_>>(),
+
                    )
+
                },
+
            )(input)
+
        }
+

+
        fn parse_author_filter(input: &str) -> IResult<&str, NotificationItemFilter> {
+
            map(
+
                preceded(
+
                    tuple((
+
                        tag_no_case("author"),
+
                        multispace0,
+
                        tag_no_case("="),
+
                        multispace0,
+
                    )),
+
                    alt((parse_author_single, parse_author_or)),
+
                ),
+
                NotificationItemFilter::Author,
+
            )(input)
+
        }
+

+
        fn parse_search_filter(input: &str) -> IResult<&str, NotificationItemFilter> {
+
            map(
+
                take_while1(|c: char| c.is_alphanumeric() || c == '_' || c == '-'),
+
                |s: &str| NotificationItemFilter::Search(s.to_string()),
+
            )(input)
+
        }
+

+
        fn parse_single_filter(input: &str) -> IResult<&str, NotificationItemFilter> {
+
            alt((
+
                parse_state_filter,
+
                parse_type_filter,
+
                parse_author_filter,
+
                parse_search_filter,
+
            ))(input)
+
        }
+

+
        fn parse_filters(input: &str) -> IResult<&str, Vec<NotificationItemFilter>> {
+
            many0(preceded(multispace0, parse_single_filter))(input)
+
        }
+

+
        let parse_filter_expression = |input: &str| -> Result<NotificationItemFilter, String> {
+
            match parse_filters(input) {
+
                Ok((remaining, filters)) => {
+
                    let remaining = remaining.trim();
+
                    if !remaining.is_empty() {
+
                        return Err(format!("Unparsed input remaining: '{}'", remaining));
+
                    }
+

+
                    if filters.is_empty() {
+
                        return Err("No filters provided".to_string());
+
                    }
+

+
                    if filters.len() == 1 {
+
                        Ok(filters.into_iter().next().unwrap())
                    } else {
-
                        search.push_str(other);
+
                        Ok(NotificationItemFilter::And(filters))
                    }
                }
+
                Err(e) => Err(format!("Parse error: {}", e)),
            }
-
        }
+
        };

-
        Ok(Self {
-
            state,
-
            type_name,
-
            authors,
-
            search: Some(search),
-
        })
+
        parse_filter_expression(filter_exp).map_err(|err| anyhow::format_err!(err))
    }
}

@@ -2028,19 +2160,61 @@ mod tests {
    }

    #[test]
-
    fn notification_item_filter_from_str_should_succeed() -> Result<()> {
-
        let search = r#"is:seen is:patch authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
+
    fn notification_item_filter_with_type_should_succeed() -> Result<()> {
+
        let search = r#"type=patch"#;
        let actual = NotificationItemFilter::from_str(search)?;

-
        let expected = NotificationItemFilter {
-
            state: Some(NotificationState::Seen),
-
            type_name: Some(NotificationType::Patch),
-
            authors: vec![
+
        let expected =
+
            NotificationItemFilter::Type(NotificationTypeFilter::Single(NotificationType::Patch));
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn notification_item_filter_with_author_should_succeed() -> Result<()> {
+
        let search = r#"author=did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB"#;
+
        let actual = NotificationItemFilter::from_str(search)?;
+

+
        let expected = NotificationItemFilter::Author(NotificationAuthorFilter::Single(
+
            Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
        ));
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn notification_item_filter_with_author_should_not_succeed() -> Result<()> {
+
        let search = r#"author=foo"#;
+
        let result = NotificationItemFilter::from_str(search);
+

+
        println!("{result:?}");
+

+
        assert!(matches!(result.unwrap_err(), anyhow::Error { .. }));
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn notification_item_filter_with_all_should_succeed() -> Result<()> {
+
        let search = r#"state=seen type=(patch or issue) author=(did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB or did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx) cli"#;
+
        let actual = NotificationItemFilter::from_str(search)?;
+

+
        let expected = NotificationItemFilter::And(vec![
+
            NotificationItemFilter::State(NotificationState::Seen),
+
            NotificationItemFilter::Type(NotificationTypeFilter::Or(vec![
+
                NotificationType::Patch,
+
                NotificationType::Issue,
+
            ])),
+
            NotificationItemFilter::Author(NotificationAuthorFilter::Or(vec![
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
-
            ],
-
            search: Some("cli".to_string()),
-
        };
+
            ])),
+
            NotificationItemFilter::Search("cli".to_string()),
+
        ]);

        assert_eq!(expected, actual);