Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
issue/list: Introduce new filter expressions
Merged did:key:z6MkgFq6...nBGz opened 3 months ago
9 files changed +490 -406 c06a8c2b bd36ed4c
modified bin/cob/issue.rs
@@ -1,129 +1,13 @@
-
use std::fmt;
-
use std::fmt::Write as _;
-

use anyhow::Result;

use radicle::cob::issue::{Issue, IssueId};
use radicle::issue::cache::Issues;
-
use radicle::issue::State;
-
use radicle::prelude::Did;
use radicle::storage::git::Repository;
use radicle::Profile;

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

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

-
impl Filter {
-
    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
-
    }
-
}
-

-
impl fmt::Display for Filter {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        if let Some(state) = &self.state {
-
            write!(f, "is:{state}")?;
-
            f.write_char(' ')?;
-
        }
-
        if self.assigned {
-
            f.write_str("is:assigned")?;
-
            f.write_char(' ')?;
-
        }
-
        if !self.assignees.is_empty() {
-
            f.write_str("assignees:")?;
-
            f.write_char('[')?;
-

-
            let mut assignees = self.assignees.iter().peekable();
-
            while let Some(assignee) = assignees.next() {
-
                f.write_str(&assignee.encode())?;
-

-
                if assignees.peek().is_some() {
-
                    f.write_char(',')?;
-
                }
-
            }
-
            f.write_char(']')?;
-
        }
-

-
        Ok(())
-
    }
-
}
-

pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(IssueId, Issue)>> {
    let cache = profile.issues(repository)?;
    let issues = cache.list()?;

    Ok(issues.flatten().collect())
}
-

-
#[cfg(test)]
-
mod tests {
-
    use std::str::FromStr;
-

-
    use anyhow::Result;
-
    use radicle::issue;
-

-
    use super::*;
-

-
    #[test]
-
    fn issue_filter_display_with_state_should_succeed() -> Result<()> {
-
        let actual = Filter::default().with_state(Some(issue::State::Open));
-

-
        assert_eq!(String::from("is:open "), actual.to_string());
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn issue_filter_display_with_state_and_assigned_should_succeed() -> Result<()> {
-
        let actual = Filter::default()
-
            .with_state(Some(issue::State::Open))
-
            .with_assgined(true);
-

-
        assert_eq!(String::from("is:open is:assigned "), actual.to_string());
-

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn issue_filter_display_with_status_and_author_should_succeed() -> Result<()> {
-
        let actual = Filter::default()
-
            .with_state(Some(issue::State::Open))
-
            .with_assginee(Did::from_str(
-
                "did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V",
-
            )?);
-

-
        assert_eq!(
-
            String::from(
-
                "is:open assignees:[did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V]"
-
            ),
-
            actual.to_string()
-
        );
-

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

@@ -11,11 +9,10 @@ use radicle::storage::{HasRepoId, ReadRepository};

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

-
use crate::commands::tui_inbox::common::InboxOperation;
use crate::terminal;
use crate::ui::items::notification::filter::{NotificationFilter, SortBy};

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

pub const HELP: Help = Help {
    name: "inbox",
deleted bin/commands/inbox/common.rs
@@ -1,19 +0,0 @@
-
use serde::Serialize;
-

-
use radicle::{identity::RepoId, node::notifications::NotificationId};
-

-
#[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 },
-
    Clear { id: NotificationId },
-
}
modified bin/commands/inbox/list.rs
@@ -6,6 +6,9 @@ use std::vec;

use anyhow::Result;

+
use serde::Serialize;
+

+
use radicle::node::notifications::NotificationId;
use ratatui::layout::{Constraint, Layout};
use ratatui::prelude::*;
use ratatui::text::Span;
@@ -29,12 +32,26 @@ use tui::ui::widget::{
use tui::ui::{BufferedValue, Show, Ui};
use tui::{Channel, Exit};

-
use super::common::RepositoryMode;
-
use crate::commands::tui_inbox::common::InboxOperation;
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 },
+
    Clear { id: NotificationId },
+
}
+

type Selection = tui::Selection<InboxOperation>;

const HELP: &str = r#"# Generic keybindings
modified bin/commands/issue.rs
@@ -1,5 +1,3 @@
-
#[path = "issue/common.rs"]
-
mod common;
#[path = "issue/list.rs"]
mod list;

@@ -11,7 +9,8 @@ use lazy_static::lazy_static;

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

use radicle_cli as cli;
@@ -20,9 +19,10 @@ use cli::terminal::patch::Message;
use cli::terminal::Context;
use cli::terminal::{Args, Error, Help};

-
use crate::cob;
-
use crate::commands::tui_issue::common::IssueOperation;
+
use crate::commands::tui_issue::list::IssueOperation;
use crate::terminal;
+
use crate::ui::items::filter::DidFilter;
+
use crate::ui::items::issue::filter::IssueFilter;
use crate::ui::TerminalInfo;

lazy_static! {
@@ -70,9 +70,67 @@ pub enum OperationName {
    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: cob::issue::Filter,
+
    filter: ListFilter,
    json: bool,
}

@@ -207,13 +265,14 @@ pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {

            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: opts.filter.clone(),
+
                    filter: (me, opts.filter.clone()).into(),
                    search: state.search.clone(),
                    issue: state.issue_id,
                    comment: state.comment_id,
@@ -270,19 +329,34 @@ pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
                                    }
                                }
                            }
-
                            IssueOperation::Solve { id } => {
+
                            IssueOperation::Solve { id, search } => {
+
                                state = PreviousState {
+
                                    issue_id: Some(id),
+
                                    comment_id: None,
+
                                    search: Some(search),
+
                                };
                                terminal::run_rad(
                                    Some("issue"),
                                    &["state".into(), id.to_string().into(), "--solved".into()],
                                )?;
                            }
-
                            IssueOperation::Close { id } => {
+
                            IssueOperation::Close { id, search } => {
+
                                state = PreviousState {
+
                                    issue_id: Some(id),
+
                                    comment_id: None,
+
                                    search: Some(search),
+
                                };
                                terminal::run_rad(
                                    Some("issue"),
                                    &["state".into(), id.to_string().into(), "--closed".into()],
                                )?;
                            }
-
                            IssueOperation::Reopen { id } => {
+
                            IssueOperation::Reopen { id, search } => {
+
                                state = PreviousState {
+
                                    issue_id: Some(id),
+
                                    comment_id: None,
+
                                    search: Some(search),
+
                                };
                                terminal::run_rad(
                                    Some("issue"),
                                    &["state".into(), id.to_string().into(), "--open".into()],
deleted bin/commands/issue/common.rs
@@ -1,31 +0,0 @@
-
use serde::Serialize;
-

-
use radicle::{cob::thread::CommentId, issue::IssueId};
-

-
/// The selected issue operation returned by the operation
-
/// selection widget.
-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub enum IssueOperation {
-
    Edit {
-
        id: IssueId,
-
        comment_id: Option<CommentId>,
-
        search: String,
-
    },
-
    Show {
-
        id: IssueId,
-
    },
-
    Close {
-
        id: IssueId,
-
    },
-
    Solve {
-
        id: IssueId,
-
    },
-
    Reopen {
-
        id: IssueId,
-
    },
-
    Comment {
-
        id: IssueId,
-
        reply_to: Option<CommentId>,
-
        search: String,
-
    },
-
}
modified bin/commands/issue/list.rs
@@ -2,6 +2,8 @@ use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::{Arc, Mutex};

+
use serde::Serialize;
+

use anyhow::{bail, Result};

use ratatui::layout::{Alignment, Constraint, Layout, Position};
@@ -31,14 +33,44 @@ use tui::{Channel, Exit};
use crate::cob::issue;
use crate::settings::{self, ThemeBundle, ThemeMode};
use crate::ui::items::filter::Filter;
-
use crate::ui::items::issue::{Issue, IssueFilter};
+
use crate::ui::items::issue::filter::IssueFilter;
+
use crate::ui::items::issue::Issue;
use crate::ui::items::HasId;
use crate::ui::{format, TerminalInfo};

-
use crate::tui_issue::common::IssueOperation;
-

type Selection = tui::Selection<IssueOperation>;

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

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

`↑,k`:      move cursor one line up
@@ -67,7 +99,7 @@ const HELP: &str = r#"# Generic keybindings
pub struct Context {
    pub profile: Profile,
    pub repository: Repository,
-
    pub filter: issue::Filter,
+
    pub filter: IssueFilter,
    pub search: Option<String>,
    pub issue: Option<IssueId>,
    pub comment: Option<CommentId>,
@@ -295,17 +327,17 @@ impl TryFrom<(&Context, &TerminalInfo)> for App {
            .collect();

        let browser = state::Browser {
-
            issues: TableState::new(
+
            issues: TableState::new(Some(
                context
                    .issue
-
                    .map(|id| {
+
                    .and_then(|id| {
                        issues
                            .iter()
                            .filter(|item| filter.matches(item))
                            .position(|item| item.id() == id)
                    })
-
                    .unwrap_or(issues.first().map(|_| 0)),
-
            ),
+
                    .unwrap_or(0),
+
            )),
            search: BufferedValue::new(TextEditState {
                text: search.read().clone(),
                cursor: search.read().len(),
@@ -447,7 +479,6 @@ impl store::Update<Message> for App {
                    None
                }
                Change::Comment { state } => {
-
                    log::info!("Change::Comments: {state:?}");
                    if let Some(item) = &self.state.preview.issue {
                        self.state.preview.selected_comments.insert(
                            item.id,
@@ -657,19 +688,28 @@ impl App {

            if ui.has_input(|key| key == Key::Char('s')) {
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Solve { id: issue.id }),
+
                    operation: Some(IssueOperation::Solve {
+
                        id: issue.id,
+
                        search: browser.search.read().text,
+
                    }),
                });
            }

            if ui.has_input(|key| key == Key::Char('l')) {
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Close { id: issue.id }),
+
                    operation: Some(IssueOperation::Close {
+
                        id: issue.id,
+
                        search: browser.search.read().text,
+
                    }),
                });
            }

            if ui.has_input(|key| key == Key::Char('o')) {
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Reopen { id: issue.id }),
+
                    operation: Some(IssueOperation::Reopen {
+
                        id: issue.id,
+
                        search: browser.search.read().text,
+
                    }),
                });
            }
        }
@@ -741,23 +781,21 @@ impl App {
            let closed = solved + other;

            let filtered_counts = format!(" {}/{} ", filtered.len(), issues.len());
-
            let mut columns = vec![
-
                Column::new(
-
                    Span::raw(" Issue ".to_string()).cyan().dim().reversed(),
-
                    Constraint::Length(7),
-
                ),
-
                Column::new(
-
                    Span::raw(format!(" {search} "))
-
                        .into_left_aligned_line()
-
                        .style(ui.theme().bar_on_black_style)
-
                        .cyan()
-
                        .dim(),
-
                    Constraint::Fill(1),
-
                ),
-
            ];

-
            if filter.state().is_none() {
-
                columns.extend_from_slice(&[
+
            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()
@@ -773,6 +811,12 @@ impl App {
                        Constraint::Length(open.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(" ● ")
                            .style(ui.theme().bar_on_black_style)
                            .into_right_aligned_line()
@@ -792,18 +836,41 @@ impl App {
                            .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::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()
            }
-

-
            columns.extend_from_slice(&[Column::new(
-
                Span::raw(filtered_counts.clone())
-
                    .into_right_aligned_line()
-
                    .cyan()
-
                    .dim()
-
                    .reversed(),
-
                Constraint::Length(filtered_counts.chars().count() as u16),
-
            )]);
-
            columns
        };

        ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
modified bin/ui/items.rs
@@ -250,39 +250,3 @@ impl ToTree<String> for CommentItem {
        vec![item]
    }
}
-

-
#[cfg(test)]
-
mod tests {
-
    use anyhow::Result;
-
    use std::str::FromStr;
-

-
    use radicle::issue::State;
-

-
    use crate::ui::items::issue::IssueFilter;
-

-
    use super::*;
-

-
    #[test]
-
    fn issue_item_filter_from_str_should_succeed() -> Result<()> {
-
        let search = r#"is:open is:assigned assignees:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
-
        let actual = IssueFilter::from_str(search)?;
-

-
        let expected = IssueFilter {
-
            state: Some(State::Open),
-
            authors: vec![Did::from_str(
-
                "did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx",
-
            )?],
-
            authored: true,
-
            assigned: true,
-
            assignees: vec![
-
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
-
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
-
            ],
-
            search: Some("cli".to_string()),
-
        };
-

-
        assert_eq!(expected, actual);
-

-
        Ok(())
-
    }
-
}
modified bin/ui/items/issue.rs
@@ -1,15 +1,9 @@
use std::fmt::Debug;
-
use std::str::FromStr;
-

-
use nom::bytes::complete::{tag, take};
-
use nom::multi::separated_list0;
-
use nom::sequence::{delimited, preceded};
-
use nom::{IResult, Parser};

use radicle::cob::thread::CommentId;
use radicle::cob::{Label, ObjectId, Timestamp};
-
use radicle::issue::{CloseReason, IssueId};
-
use radicle::prelude::Did;
+
use radicle::issue::IssueId;
+

use radicle::Profile;

use ratatui::style::{Style, Stylize};
@@ -21,7 +15,6 @@ use tui::ui::span;
use tui::ui::ToRow;

use crate::ui::format;
-
use crate::ui::items::filter::Filter;
use crate::ui::items::{AuthorItem, CommentItem, HasId};

#[derive(Clone, Debug)]
@@ -149,168 +142,306 @@ impl HasId for Issue {
    }
}

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub(crate) struct IssueFilter {
-
    pub(crate) state: Option<radicle::issue::State>,
-
    pub(crate) authored: bool,
-
    pub(crate) authors: Vec<Did>,
-
    pub(crate) assigned: bool,
-
    pub(crate) assignees: Vec<Did>,
-
    pub(crate) search: Option<String>,
-
}
+
pub mod filter {
+
    use std::fmt;
+
    use std::fmt::Debug;
+
    use std::fmt::Write as _;
+
    use std::str::FromStr;
+

+
    use nom::branch::alt;
+
    use nom::bytes::complete::{tag_no_case, take_while1};
+
    use nom::character::complete::multispace0;
+
    use nom::combinator::{map, value};
+
    use nom::multi::many0;
+
    use nom::sequence::preceded;
+
    use nom::IResult;
+

+
    use radicle::issue::CloseReason;
+
    use radicle::issue::State;
+

+
    use crate::ui::items::filter;
+
    use crate::ui::items::filter::DidFilter;
+
    use crate::ui::items::filter::Filter;
+

+
    use super::Issue;
+

+
    #[derive(Clone, Debug, Eq, PartialEq)]
+
    pub enum IssueFilter {
+
        State(State),
+
        Author(DidFilter),
+
        Assignee(DidFilter),
+
        Search(String),
+
        And(Vec<IssueFilter>),
+
        Empty,
+
        Invalid,
+
    }

-
impl IssueFilter {
-
    pub fn state(&self) -> Option<radicle::issue::State> {
-
        self.state
+
    impl Default for IssueFilter {
+
        fn default() -> Self {
+
            IssueFilter::State(State::Open)
+
        }
    }
-
}

-
impl Filter<Issue> for IssueFilter {
-
    fn matches(&self, issue: &Issue) -> bool {
-
        use fuzzy_matcher::skim::SkimMatcherV2;
-
        use fuzzy_matcher::FuzzyMatcher;
-
        use radicle::issue::State;
+
    impl IssueFilter {
+
        pub fn is_default(&self) -> bool {
+
            *self == IssueFilter::default()
+
        }

-
        let matcher = SkimMatcherV2::default();
+
        pub fn has_state(&self) -> bool {
+
            match self {
+
                IssueFilter::State(_) => true,
+
                IssueFilter::And(filters) => {
+
                    filters.iter().any(|f| matches!(f, IssueFilter::State(_)))
+
                }
+
                _ => false,
+
            }
+
        }
+
    }

-
        let matches_state = match self.state {
-
            Some(State::Closed {
-
                reason: CloseReason::Other,
-
            }) => matches!(issue.state, State::Closed { .. }),
-
            Some(state) => issue.state == state,
-
            None => true,
-
        };
+
    impl fmt::Display for IssueFilter {
+
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
            match self {
+
                IssueFilter::State(state) => {
+
                    let state = match state {
+
                        State::Open => "open",
+
                        State::Closed { reason } => match reason {
+
                            CloseReason::Solved => "solved",
+
                            CloseReason::Other => "closed",
+
                        },
+
                    };
+
                    write!(f, "state={state}")?;
+
                    f.write_char(' ')?;
+
                }
+
                IssueFilter::Author(filter) => {
+
                    write!(f, "author={filter}")?;
+
                    f.write_char(' ')?;
+
                }
+
                IssueFilter::Assignee(filter) => {
+
                    write!(f, "assignee={filter}")?;
+
                    f.write_char(' ')?;
+
                }
+
                IssueFilter::Search(search) => {
+
                    write!(f, "{search}")?;
+
                    f.write_char(' ')?;
+
                }
+
                IssueFilter::And(filters) => {
+
                    let mut it = filters.iter().peekable();
+
                    while let Some(filter) = it.next() {
+
                        write!(f, "{filter}")?;
+
                        if it.peek().is_none() {
+
                            f.write_char(' ')?;
+
                        }
+
                    }
+
                }
+
                IssueFilter::Empty | IssueFilter::Invalid => {}
+
            }

-
        let matches_authored = if self.authored {
-
            issue.author.you
-
        } else {
-
            true
-
        };
+
            Ok(())
+
        }
+
    }

-
        let matches_authors = if !self.authors.is_empty() {
-
            {
-
                self.authors
-
                    .iter()
-
                    .any(|other| issue.author.nid == Some(**other))
-
            }
-
        } else {
-
            true
-
        };
+
    impl Filter<Issue> for IssueFilter {
+
        fn matches(&self, issue: &Issue) -> bool {
+
            use fuzzy_matcher::skim::SkimMatcherV2;
+
            use fuzzy_matcher::FuzzyMatcher;

-
        let matches_assigned = if self.assigned {
-
            issue.assignees.iter().any(|assignee| assignee.you)
-
        } else {
-
            true
-
        };
+
            let matcher = SkimMatcherV2::default();

-
        let matches_assignees = if !self.assignees.is_empty() {
-
            {
-
                self.assignees.iter().any(|other| {
-
                    issue
+
            match self {
+
                IssueFilter::State(state) => issue.state == *state,
+
                IssueFilter::Author(author_filter) => match author_filter {
+
                    DidFilter::Single(author) => issue.author.nid == Some(**author),
+
                    DidFilter::Or(authors) => authors
+
                        .iter()
+
                        .any(|other| issue.author.nid == Some(**other)),
+
                },
+
                IssueFilter::Assignee(assignee_filter) => match assignee_filter {
+
                    DidFilter::Single(assignee) => issue
                        .assignees
                        .iter()
-
                        .filter_map(|author| author.nid)
-
                        .collect::<Vec<_>>()
-
                        .contains(other)
-
                })
+
                        .any(|other| other.nid == Some(**assignee)),
+
                    DidFilter::Or(assignees) => issue.assignees.iter().any(|other| {
+
                        assignees
+
                            .iter()
+
                            .any(|assignee| other.nid == Some(**assignee))
+
                    }),
+
                },
+
                IssueFilter::Search(search) => {
+
                    match matcher.fuzzy_match(
+
                        &format!(
+
                            "{} {} {}",
+
                            &issue.id.to_string(),
+
                            &issue.title,
+
                            &issue
+
                                .author
+
                                .alias
+
                                .as_ref()
+
                                .map(|a| a.to_string())
+
                                .unwrap_or_default()
+
                        ),
+
                        search,
+
                    ) {
+
                        Some(score) => score == 0 || score > filter::FUZZY_MIN_SCORE,
+
                        _ => false,
+
                    }
+
                }
+
                IssueFilter::And(filters) => filters.iter().all(|f| f.matches(issue)),
+
                IssueFilter::Empty => true,
+
                IssueFilter::Invalid => false,
            }
-
        } else {
-
            true
-
        };
+
        }
+
    }

-
        let matches_search = match &self.search {
-
            Some(search) => match matcher.fuzzy_match(&issue.title, search) {
-
                Some(score) => score == 0 || score > 60,
-
                _ => false,
-
            },
-
            None => true,
-
        };
+
    impl FromStr for IssueFilter {
+
        type Err = anyhow::Error;
+

+
        fn from_str(filter_exp: &str) -> Result<Self, Self::Err> {
+
            use nom::Parser;
+

+
            fn parse_state(input: &str) -> IResult<&str, State> {
+
                alt((
+
                    value(State::Open, tag_no_case("open")),
+
                    value(
+
                        State::Closed {
+
                            reason: radicle::issue::CloseReason::Other,
+
                        },
+
                        tag_no_case("closed"),
+
                    ),
+
                    value(
+
                        State::Closed {
+
                            reason: radicle::issue::CloseReason::Solved,
+
                        },
+
                        tag_no_case("solved"),
+
                    ),
+
                ))
+
                .parse(input)
+
            }

-
        matches_state
-
            && matches_authored
-
            && matches_authors
-
            && matches_assigned
-
            && matches_assignees
-
            && matches_search
-
    }
-
}
+
            fn parse_state_filter(input: &str) -> IResult<&str, IssueFilter> {
+
                map(
+
                    preceded(
+
                        (
+
                            tag_no_case("state"),
+
                            multispace0,
+
                            tag_no_case("="),
+
                            multispace0,
+
                        ),
+
                        parse_state,
+
                    ),
+
                    IssueFilter::State,
+
                )
+
                .parse(input)
+
            }

-
impl FromStr for IssueFilter {
-
    type Err = anyhow::Error;
-

-
    fn from_str(value: &str) -> Result<Self, Self::Err> {
-
        use radicle::issue::State;
-

-
        let mut state = None;
-
        let mut search = String::new();
-
        let mut authored = false;
-
        let mut authors = vec![];
-
        let mut assigned = false;
-
        let mut assignees = vec![];
-

-
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("authors:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )
-
            .parse(input)
-
        };
+
            fn parse_assignee_filter(input: &str) -> IResult<&str, IssueFilter> {
+
                map(
+
                    preceded(
+
                        (
+
                            tag_no_case("assignee"),
+
                            multispace0,
+
                            tag_no_case("="),
+
                            multispace0,
+
                        ),
+
                        alt((filter::parse_did_single, filter::parse_did_or)),
+
                    ),
+
                    IssueFilter::Assignee,
+
                )
+
                .parse(input)
+
            }

-
        let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
-
            preceded(
-
                tag("assignees:"),
-
                delimited(
-
                    tag("["),
-
                    separated_list0(tag(","), take(56_usize)),
-
                    tag("]"),
-
                ),
-
            )
-
            .parse(input)
-
        };
+
            fn parse_author_filter(input: &str) -> IResult<&str, IssueFilter> {
+
                map(
+
                    preceded(
+
                        (
+
                            tag_no_case("author"),
+
                            multispace0,
+
                            tag_no_case("="),
+
                            multispace0,
+
                        ),
+
                        alt((filter::parse_did_single, filter::parse_did_or)),
+
                    ),
+
                    IssueFilter::Author,
+
                )
+
                .parse(input)
+
            }

-
        let parts = value.split(' ');
-
        for part in parts {
-
            match part {
-
                "is:open" => state = Some(State::Open),
-
                "is:closed" => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Other,
-
                    })
-
                }
-
                "is:solved" => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Solved,
-
                    })
-
                }
-
                "is:authored" => authored = true,
-
                "is:assigned" => assigned = true,
-
                other => {
-
                    if let Ok((_, dids)) = assignees_parser.parse(other) {
-
                        for did in dids {
-
                            assignees.push(Did::from_str(did)?);
+
            fn parse_search_filter(input: &str) -> IResult<&str, IssueFilter> {
+
                map(
+
                    take_while1(|c: char| c.is_alphanumeric() || c == '_' || c == '-'),
+
                    |s: &str| IssueFilter::Search(s.to_string()),
+
                )
+
                .parse(input)
+
            }
+

+
            fn parse_single_filter(input: &str) -> IResult<&str, IssueFilter> {
+
                alt((
+
                    parse_state_filter,
+
                    parse_assignee_filter,
+
                    parse_author_filter,
+
                    parse_search_filter,
+
                ))
+
                .parse(input)
+
            }
+

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

+
            let parse_filter_expression = |input: &str| -> Result<IssueFilter, String> {
+
                match parse_filters(input) {
+
                    Ok((remaining, filters)) => {
+
                        let remaining = remaining.trim();
+
                        if !remaining.is_empty() {
+
                            return Err(format!("Unparsed input remaining: '{remaining}'"));
                        }
-
                    } else if let Ok((_, dids)) = authors_parser.parse(other) {
-
                        for did in dids {
-
                            authors.push(Did::from_str(did)?);
+

+
                        if filters.is_empty() {
+
                            return Ok(IssueFilter::Empty);
+
                        }
+

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

+
            parse_filter_expression(filter_exp).map_err(|err| anyhow::format_err!(err))
        }
+
    }
+
}

-
        Ok(Self {
-
            state,
-
            authored,
-
            authors,
-
            assigned,
-
            assignees,
-
            search: Some(search),
-
        })
+
#[cfg(test)]
+
mod tests {
+
    use anyhow::Result;
+
    use std::str::FromStr;
+

+
    use radicle::{issue::State, prelude::Did};
+

+
    use crate::ui::items::{filter::DidFilter, issue::filter::IssueFilter};
+

+
    #[test]
+
    fn issue_item_filter_from_str_should_succeed() -> Result<()> {
+
        let search = r#"state=open assignee=(did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB or did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx) author=did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx cli"#;
+
        let actual = IssueFilter::from_str(search)?;
+

+
        let expected = IssueFilter::And(vec![
+
            IssueFilter::State(State::Open),
+
            IssueFilter::Assignee(DidFilter::Or(vec![
+
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
+
            ])),
+
            IssueFilter::Author(DidFilter::Single(Did::from_str(
+
                "did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx",
+
            )?)),
+
            IssueFilter::Search("cli".to_string()),
+
        ]);
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
    }
}