Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
issue: Add search widget
Merged did:key:z6MkgFq6...nBGz opened 2 years ago
6 files changed +496 -183 79adc448 6371ef0a
modified bin/commands/issue.rs
@@ -11,10 +11,12 @@ use std::ffi::OsString;

use anyhow::anyhow;

+
use radicle::issue;
+

use radicle::identity::RepoId;
use radicle_tui as tui;

-
use tui::common::cob::issue::{self, State};
+
use tui::common::cob;
use tui::common::log;

use crate::terminal;
@@ -59,7 +61,7 @@ pub enum OperationName {
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SelectOptions {
    mode: common::Mode,
-
    filter: issue::Filter,
+
    filter: cob::issue::Filter,
}

impl Args for Options {
@@ -92,13 +94,19 @@ impl Args for Options {
                    select_opts.filter = select_opts.filter.with_state(None);
                }
                Long("open") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(Some(State::Open));
+
                    select_opts.filter = select_opts.filter.with_state(Some(issue::State::Open));
                }
                Long("solved") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(Some(State::Solved));
+
                    select_opts.filter =
+
                        select_opts.filter.with_state(Some(issue::State::Closed {
+
                            reason: issue::CloseReason::Solved,
+
                        }));
                }
                Long("closed") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(Some(State::Closed));
+
                    select_opts.filter =
+
                        select_opts.filter.with_state(Some(issue::State::Closed {
+
                            reason: issue::CloseReason::Other,
+
                        }));
                }
                Long("assigned") if op == Some(OperationName::Select) => {
                    if let Ok(val) = parser.value() {
modified bin/commands/issue/flux/select.rs
@@ -10,7 +10,7 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::common::cob::issue::{self, Filter};
-
use tui::flux::store::{State, Store};
+
use tui::flux::store::{self, StateValue};
use tui::flux::task::{self, Interrupted};
use tui::flux::ui::cob::IssueItem;
use tui::flux::ui::Frontend;
@@ -36,50 +36,48 @@ pub struct App {
#[derive(Clone, Debug)]
pub struct UIState {
    page_size: usize,
+
    show_search: bool,
}

impl Default for UIState {
    fn default() -> Self {
-
        Self { page_size: 1 }
+
        Self {
+
            page_size: 1,
+
            show_search: false,
+
        }
    }
}

#[derive(Clone, Debug)]
-
pub struct IssuesState {
+
pub struct State {
    issues: Vec<IssueItem>,
    selected: Option<IssueItem>,
    mode: Mode,
-
    filter: Filter,
+
    search: StateValue<String>,
    ui: UIState,
}

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

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
        let issues = issue::all(&context.profile, &context.repository)?;
-
        let issues = issues
-
            .iter()
-
            .filter(|(_, issue)| context.filter.matches(&context.profile, issue));
-

-
        let mut items = vec![];

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

-
        // Apply sorting
-
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
        let selected = items.first().cloned();

        Ok(Self {
            issues: items,
            selected,
            mode: context.mode.clone(),
-
            filter: context.filter.clone(),
+
            search: StateValue::new(context.filter.to_string()),
            ui: UIState::default(),
        })
    }
@@ -89,13 +87,18 @@ pub enum Action {
    Exit { selection: Option<Selection> },
    Select { item: IssueItem },
    PageSize(usize),
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
}

-
impl State<Action, Selection> for IssuesState {
+
impl store::State<Action, Selection> for State {
    fn tick(&self) {}

    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
        match action {
+
            Action::Exit { selection } => Some(Exit { value: selection }),
            Action::Select { item } => {
                self.selected = Some(item);
                None
@@ -104,7 +107,24 @@ impl State<Action, Selection> for IssuesState {
                self.ui.page_size = size;
                None
            }
-
            Action::Exit { selection } => Some(Exit { value: selection }),
+
            Action::OpenSearch => {
+
                self.ui.show_search = true;
+
                None
+
            }
+
            Action::UpdateSearch { value } => {
+
                self.search.write(value);
+
                None
+
            }
+
            Action::ApplySearch => {
+
                self.search.apply();
+
                self.ui.show_search = false;
+
                None
+
            }
+
            Action::CloseSearch => {
+
                self.search.reset();
+
                self.ui.show_search = false;
+
                None
+
            }
        }
    }
}
@@ -116,16 +136,13 @@ impl App {

    pub async fn run(&self) -> Result<Option<Selection>> {
        let (terminator, mut interrupt_rx) = task::create_termination();
-
        let (store, state_rx) = Store::<Action, IssuesState, Selection>::new();
+
        let (store, state_rx) = store::Store::<Action, State, Selection>::new();
        let (frontend, action_rx) = Frontend::<Action>::new();
-
        let state = IssuesState::try_from(&self.context)?;
+
        let state = State::try_from(&self.context)?;

        tokio::try_join!(
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
-
            frontend.main_loop::<IssuesState, ListPage, Selection>(
-
                state_rx,
-
                interrupt_rx.resubscribe()
-
            ),
+
            frontend.main_loop::<State, ListPage, Selection>(state_rx, interrupt_rx.resubscribe()),
        )?;

        if let Ok(reason) = interrupt_rx.recv().await {
modified bin/commands/issue/flux/select/ui.rs
@@ -1,22 +1,23 @@
use std::collections::HashMap;
+
use std::str::FromStr;
use std::vec;

-
use radicle::issue;
-
use ratatui::style::Stylize;
-
use ratatui::text::Line;
+
use radicle::issue::{self, CloseReason};
use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;

use ratatui::backend::Backend;
-
use ratatui::layout::{Constraint, Direction, Layout, Rect};
+
use ratatui::layout::{Constraint, Layout, Rect};
+
use ratatui::style::Stylize;
+
use ratatui::text::Line;

use radicle_tui as tui;

-
use tui::common::cob::issue::{Filter, State};
-
use tui::flux::ui::cob::IssueItem;
+
use tui::flux::ui::cob::{IssueItem, IssueItemFilter};
use tui::flux::ui::span;
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
+
use tui::flux::ui::widget::input::{TextField, TextFieldProps};
use tui::flux::ui::widget::{
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
};
@@ -25,18 +26,20 @@ use tui::Selection;
use crate::tui_issue::common::IssueOperation;
use crate::tui_issue::common::Mode;

-
use super::{Action, IssuesState};
+
use super::{Action, State};

pub struct ListPageProps {
    selected: Option<IssueItem>,
    mode: Mode,
+
    show_search: bool,
}

-
impl From<&IssuesState> for ListPageProps {
-
    fn from(state: &IssuesState) -> Self {
+
impl From<&State> for ListPageProps {
+
    fn from(state: &State) -> Self {
        Self {
            selected: state.selected.clone(),
            mode: state.mode.clone(),
+
            show_search: state.ui.show_search,
        }
    }
}
@@ -48,12 +51,14 @@ pub struct ListPage {
    props: ListPageProps,
    /// Notification widget
    issues: Issues,
+
    /// Search widget
+
    search: Search,
    /// Shortcut widget
    shortcuts: Shortcuts<Action>,
}

-
impl Widget<IssuesState, Action> for ListPage {
-
    fn new(state: &IssuesState, action_tx: UnboundedSender<Action>) -> Self
+
impl Widget<State, Action> for ListPage {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
@@ -61,12 +66,13 @@ impl Widget<IssuesState, Action> for ListPage {
            action_tx: action_tx.clone(),
            props: ListPageProps::from(state),
            issues: Issues::new(state, action_tx.clone()),
+
            search: Search::new(state, action_tx.clone()),
            shortcuts: Shortcuts::new(state, action_tx.clone()),
        }
        .move_with_state(state)
    }

-
    fn move_with_state(self, state: &IssuesState) -> Self
+
    fn move_with_state(self, state: &State) -> Self
    where
        Self: Sized,
    {
@@ -83,38 +89,45 @@ impl Widget<IssuesState, Action> for ListPage {
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        match key {
-
            Key::Esc | Key::Ctrl('c') => {
-
                let _ = self.action_tx.send(Action::Exit { selection: None });
-
            }
-
            Key::Char('\n') => {
-
                if let Some(selected) = &self.props.selected {
-
                    let operation = match self.props.mode {
-
                        Mode::Operation => Some(IssueOperation::Show.to_string()),
-
                        Mode::Id => None,
-
                    };
-
                    let _ = self.action_tx.send(Action::Exit {
-
                        selection: Some(Selection {
-
                            operation,
-
                            ids: vec![selected.id],
-
                            args: vec![],
-
                        }),
-
                    });
+
        if self.props.show_search {
+
            <Search as Widget<State, Action>>::handle_key_event(&mut self.search, key)
+
        } else {
+
            match key {
+
                Key::Esc | Key::Ctrl('c') => {
+
                    let _ = self.action_tx.send(Action::Exit { selection: None });
                }
-
            }
-
            Key::Char('e') => {
-
                if let Some(selected) = &self.props.selected {
-
                    let _ = self.action_tx.send(Action::Exit {
-
                        selection: Some(Selection {
-
                            operation: Some(IssueOperation::Edit.to_string()),
-
                            ids: vec![selected.id],
-
                            args: vec![],
-
                        }),
-
                    });
+
                Key::Char('\n') => {
+
                    if let Some(selected) = &self.props.selected {
+
                        let operation = match self.props.mode {
+
                            Mode::Operation => Some(IssueOperation::Show.to_string()),
+
                            Mode::Id => None,
+
                        };
+
                        let _ = self.action_tx.send(Action::Exit {
+
                            selection: Some(Selection {
+
                                operation,
+
                                ids: vec![selected.id],
+
                                args: vec![],
+
                            }),
+
                        });
+
                    }
+
                }
+
                Key::Char('e') => {
+
                    if let Some(selected) = &self.props.selected {
+
                        let _ = self.action_tx.send(Action::Exit {
+
                            selection: Some(Selection {
+
                                operation: Some(IssueOperation::Edit.to_string()),
+
                                ids: vec![selected.id],
+
                                args: vec![],
+
                            }),
+
                        });
+
                    }
+
                }
+
                Key::Char('/') => {
+
                    let _ = self.action_tx.send(Action::OpenSearch);
+
                }
+
                _ => {
+
                    <Issues as Widget<State, Action>>::handle_key_event(&mut self.issues, key);
                }
-
            }
-
            _ => {
-
                <Issues as Widget<IssuesState, Action>>::handle_key_event(&mut self.issues, key);
            }
        }
    }
@@ -125,17 +138,36 @@ impl Render<()> for ListPage {
        let area = frame.size();
        let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);

-
        let shortcuts = match self.props.mode {
-
            Mode::Id => vec![Shortcut::new("enter", "select")],
-
            Mode::Operation => vec![
-
                Shortcut::new("enter", "show"),
-
                Shortcut::new("c", "comment"),
-
                Shortcut::new("e", "edit"),
-
                Shortcut::new("d", "delete"),
-
            ],
+
        let shortcuts = if self.props.show_search {
+
            vec![
+
                Shortcut::new("esc", "back"),
+
                Shortcut::new("enter", "search"),
+
            ]
+
        } else {
+
            match self.props.mode {
+
                Mode::Id => vec![
+
                    Shortcut::new("enter", "select"),
+
                    Shortcut::new("/", "search"),
+
                ],
+
                Mode::Operation => vec![
+
                    Shortcut::new("enter", "show"),
+
                    Shortcut::new("e", "edit"),
+
                    Shortcut::new("/", "search"),
+
                ],
+
            }
        };

-
        self.issues.render::<B>(frame, layout.component, ());
+
        if self.props.show_search {
+
            let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
+
                .split(layout.component);
+

+
            self.issues.render::<B>(frame, component_layout[0], ());
+
            self.search
+
                .render::<B>(frame, component_layout[1], SearchProps {});
+
        } else {
+
            self.issues.render::<B>(frame, layout.component, ());
+
        }
+

        self.shortcuts.render::<B>(
            frame,
            layout.shortcuts,
@@ -149,31 +181,60 @@ impl Render<()> for ListPage {

struct IssuesProps {
    issues: Vec<IssueItem>,
-
    filter: Filter,
+
    search: String,
    stats: HashMap<String, usize>,
    widths: [Constraint; 8],
    cutoff: usize,
    cutoff_after: usize,
    focus: bool,
    page_size: usize,
+
    show_search: bool,
}

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

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

+
        // Filter by search string
+
        let filter = IssueItemFilter::from_str(&state.search.read()).unwrap_or_default();
+
        let mut issues = state
+
            .issues
+
            .clone()
+
            .into_iter()
+
            .filter(|issue| filter.matches(issue))
+
            .collect::<Vec<_>>();
+

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

        for issue in &state.issues {
            match issue.state {
-
                issue::State::Open => open += 1,
-
                issue::State::Closed { reason: _ } => closed += 1,
+
                State::Open => open += 1,
+
                State::Closed {
+
                    reason: CloseReason::Other,
+
                } => other += 1,
+
                State::Closed {
+
                    reason: CloseReason::Solved,
+
                } => solved += 1,
            }
        }
-
        let stats = HashMap::from([("Open".to_string(), open), ("Closed".to_string(), closed)]);
+

+
        let closed = solved + other;
+

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

        Self {
-
            issues: state.issues.clone(),
-
            filter: state.filter.clone(),
+
            issues,
+
            search: state.search.read(),
            widths: [
                Constraint::Length(3),
                Constraint::Length(8),
@@ -189,6 +250,7 @@ impl From<&IssuesState> for IssuesProps {
            focus: false,
            stats,
            page_size: state.ui.page_size,
+
            show_search: state.ui.show_search,
        }
    }
}
@@ -206,8 +268,8 @@ struct Issues {
    footer: Footer<Action>,
}

-
impl Widget<IssuesState, Action> for Issues {
-
    fn new(state: &IssuesState, action_tx: UnboundedSender<Action>) -> Self {
+
impl Widget<State, Action> for Issues {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
        Self {
            action_tx: action_tx.clone(),
            props: IssuesProps::from(state),
@@ -217,13 +279,22 @@ impl Widget<IssuesState, Action> for Issues {
        }
    }

-
    fn move_with_state(self, state: &IssuesState) -> Self
+
    fn move_with_state(self, state: &State) -> Self
    where
        Self: Sized,
    {
+
        let props = IssuesProps::from(state);
+
        let mut table = self.table.move_with_state(state);
+

+
        if let Some(selected) = table.selected() {
+
            if selected > props.issues.len() {
+
                table.begin();
+
            }
+
        }
+

        Self {
-
            props: IssuesProps::from(state),
-
            table: self.table.move_with_state(state),
+
            props,
+
            table,
            header: self.header.move_with_state(state),
            footer: self.footer.move_with_state(state),
            ..self
@@ -300,7 +371,7 @@ impl Issues {
            area,
            TableProps {
                items: self.props.issues.to_vec(),
-
                has_footer: true,
+
                has_footer: !self.props.show_search,
                has_header: true,
                widths: self.props.widths,
                focus: self.props.focus,
@@ -311,17 +382,31 @@ impl Issues {
    }

    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        let filter = Line::from(
+
        let search = if self.props.search.is_empty() {
+
            Line::from([span::default(self.props.search.to_string()).magenta().dim()].to_vec())
+
        } else {
+
            Line::from(
+
                [
+
                    span::default(" / ".to_string()).magenta().dim(),
+
                    span::default(self.props.search.to_string()).magenta().dim(),
+
                ]
+
                .to_vec(),
+
            )
+
        };
+

+
        let open = Line::from(
            [
-
                span::default(" ".to_string()),
-
                span::default(self.props.filter.to_string()).magenta().dim(),
+
                span::positive(self.props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
                span::default(" Open".to_string()).dim(),
            ]
            .to_vec(),
        );
-
        let open = Line::from(
+
        let solved = Line::from(
            [
-
                span::positive(self.props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
                span::default(" Open".to_string()).dim(),
+
                span::default(self.props.stats.get("Solved").unwrap_or(&0).to_string())
+
                    .magenta()
+
                    .dim(),
+
                span::default(" Solved".to_string()).dim(),
            ]
            .to_vec(),
        );
@@ -347,18 +432,26 @@ impl Issues {
            .progress_percentage(self.props.issues.len(), self.props.page_size);
        let progress = span::default(format!("{}%", progress)).dim();

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

                self.footer.render::<B>(
                    frame,
                    area,
                    FooterProps {
-
                        cells: [filter.into(), block.clone().into(), progress.clone().into()],
+
                        cells: [search.into(), block.clone().into(), progress.clone().into()],
                        widths: [
                            Constraint::Fill(1),
                            Constraint::Min(block.width() as u16),
@@ -376,7 +469,7 @@ impl Issues {
                    area,
                    FooterProps {
                        cells: [
-
                            filter.into(),
+
                            search.into(),
                            open.clone().into(),
                            closed.clone().into(),
                            sum.clone().into(),
@@ -401,24 +494,98 @@ impl Issues {

impl Render<()> for Issues {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![
+
        let page_size = if self.props.show_search {
+
            let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area);
+

+
            self.render_header::<B>(frame, layout[0]);
+
            self.render_list::<B>(frame, layout[1]);
+

+
            layout[1].height as usize
+
        } else {
+
            let layout = Layout::vertical([
                Constraint::Length(3),
                Constraint::Min(1),
                Constraint::Length(3),
            ])
            .split(area);

-
        self.render_header::<B>(frame, layout[0]);
-
        self.render_list::<B>(frame, layout[1]);
-
        self.render_footer::<B>(frame, layout[2]);
+
            self.render_header::<B>(frame, layout[0]);
+
            self.render_list::<B>(frame, layout[1]);
+
            self.render_footer::<B>(frame, layout[2]);
+

+
            layout[1].height as usize
+
        };

-
        let page_size = layout[1].height as usize;
        if page_size != self.props.page_size {
-
            let _ = self
-
                .action_tx
-
                .send(Action::PageSize(layout[1].height as usize));
+
            let _ = self.action_tx.send(Action::PageSize(page_size));
        }
    }
}
+

+
pub struct SearchProps {}
+

+
pub struct Search {
+
    pub action_tx: UnboundedSender<Action>,
+
    pub input: TextField,
+
}
+

+
impl Widget<State, Action> for Search {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        let mut input = TextField::new(state, action_tx.clone());
+
        input.set_text(&state.search.read().to_string());
+

+
        Self { action_tx, input }.move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &State) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        let mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
+
        input.set_text(&state.search.read().to_string());
+

+
        Self { input, ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "filter-popup"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        match key {
+
            Key::Esc => {
+
                let _ = self.action_tx.send(Action::CloseSearch);
+
            }
+
            Key::Char('\n') => {
+
                let _ = self.action_tx.send(Action::ApplySearch);
+
            }
+
            _ => {
+
                <TextField as Widget<State, Action>>::handle_key_event(&mut self.input, key);
+
                let _ = self.action_tx.send(Action::UpdateSearch {
+
                    value: self.input.text().to_string(),
+
                });
+
            }
+
        }
+
    }
+
}
+

+
impl Render<SearchProps> for Search {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: SearchProps) {
+
        let layout = Layout::horizontal(Constraint::from_mins([0]))
+
            .horizontal_margin(1)
+
            .split(area);
+

+
        self.input.render::<B>(
+
            frame,
+
            layout[0],
+
            TextFieldProps {
+
                titles: ("/".into(), "Search".into()),
+
                show_cursor: true,
+
                inline_label: true,
+
            },
+
        );
+
    }
+
}
modified src/common/cob/issue.rs
@@ -1,34 +1,12 @@
-
use std::fmt::Display;
-

use anyhow::Result;
+

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

-
use super::format;
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum State {
-
    #[default]
-
    Open,
-
    Solved,
-
    Closed,
-
}
-

-
impl Display for State {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        let state = match self {
-
            State::Open => "open",
-
            State::Solved => "solved",
-
            State::Closed => "closed",
-
        };
-
        f.write_str(state)
-
    }
-
}
+
use radicle::Profile;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Filter {
@@ -62,44 +40,6 @@ impl Filter {
        self.assignees.push(assignee);
        self
    }
-

-
    pub fn state(&self) -> Option<State> {
-
        self.state.clone()
-
    }
-

-
    pub fn matches(&self, profile: &Profile, issue: &Issue) -> bool {
-
        let matches_state = match self.state {
-
            Some(State::Open) => matches!(issue.state(), issue::State::Open),
-
            Some(State::Solved) => matches!(
-
                issue.state(),
-
                issue::State::Closed {
-
                    reason: CloseReason::Solved
-
                }
-
            ),
-
            Some(State::Closed) => matches!(issue.state(), issue::State::Closed { .. }),
-
            None => true,
-
        };
-

-
        let matches_assgined = self
-
            .assigned
-
            .then(|| {
-
                issue
-
                    .assignees()
-
                    .collect::<Vec<_>>()
-
                    .contains(&&profile.did())
-
            })
-
            .unwrap_or(true);
-

-
        let matches_assignees = (!self.assignees.is_empty())
-
            .then(|| {
-
                self.assignees
-
                    .iter()
-
                    .any(|other| issue.assignees().collect::<Vec<_>>().contains(&other))
-
            })
-
            .unwrap_or(true);
-

-
        matches_state && matches_assgined && matches_assignees
-
    }
}

impl ToString for Filter {
@@ -120,7 +60,7 @@ impl ToString for Filter {

            let mut assignees = self.assignees.iter().peekable();
            while let Some(assignee) = assignees.next() {
-
                filter.push_str(&format::did(assignee));
+
                filter.push_str(&assignee.encode());

                if assignees.peek().is_some() {
                    filter.push(',');
@@ -159,3 +99,51 @@ pub fn create<G: Signer>(

    Ok(*issue.id())
}
+

+
#[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 src/common/cob/patch.rs
@@ -59,7 +59,7 @@ impl ToString for Filter {

            let mut authors = self.authors.iter().peekable();
            while let Some(author) = authors.next() {
-
                filter.push_str(&author.to_string());
+
                filter.push_str(&author.encode());

                if authors.peek().is_some() {
                    filter.push(',');
@@ -122,7 +122,9 @@ mod tests {
            )?);

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

modified src/flux/ui/cob.rs
@@ -9,8 +9,7 @@ use radicle::cob::{Label, ObjectId, Timestamp, TypedId};
use radicle::crypto::PublicKey;
use radicle::git::Oid;
use radicle::identity::{Did, Identity};
-
use radicle::issue;
-
use radicle::issue::{Issue, IssueId, Issues};
+
use radicle::issue::{self, CloseReason, Issue, IssueId, Issues};
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
use radicle::node::{Alias, AliasStore, NodeId};
use radicle::patch;
@@ -27,7 +26,7 @@ use super::theme::style;
use super::widget::ToRow;
use super::{format, span};

-
#[derive(Clone, Debug)]
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthorItem {
    pub nid: Option<NodeId>,
    pub alias: Option<Alias>,
@@ -405,6 +404,118 @@ impl ToRow<8> for IssueItem {
    }
}

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

+
impl IssueItemFilter {
+
    pub fn state(&self) -> Option<issue::State> {
+
        self.state
+
    }
+

+
    pub fn matches(&self, issue: &IssueItem) -> bool {
+
        use fuzzy_matcher::skim::SkimMatcherV2;
+
        use fuzzy_matcher::FuzzyMatcher;
+

+
        let matcher = SkimMatcherV2::default();
+

+
        let matches_state = match self.state {
+
            Some(issue::State::Closed {
+
                reason: CloseReason::Other,
+
            }) => matches!(issue.state, issue::State::Closed { .. }),
+
            Some(state) => issue.state == state,
+
            None => true,
+
        };
+

+
        let matches_assigned = self
+
            .assigned
+
            .then(|| issue.assignees.iter().any(|assignee| assignee.you))
+
            .unwrap_or(true);
+

+
        let matches_assignees = (!self.assignees.is_empty())
+
            .then(|| {
+
                self.assignees.iter().any(|other| {
+
                    issue
+
                        .assignees
+
                        .iter()
+
                        .filter_map(|author| author.nid)
+
                        .collect::<Vec<_>>()
+
                        .contains(other)
+
                })
+
            })
+
            .unwrap_or(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,
+
        };
+

+
        matches_state && matches_assigned && matches_assignees && matches_search
+
    }
+
}
+

+
impl FromStr for IssueItemFilter {
+
    type Err = anyhow::Error;
+

+
    fn from_str(value: &str) -> Result<Self, Self::Err> {
+
        let mut state = None;
+
        let mut search = String::new();
+
        let mut assigned = false;
+
        let mut assignees = vec![];
+

+
        let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("assignees:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )(input)
+
        };
+

+
        let parts = value.split(' ');
+
        for part in parts {
+
            match part {
+
                "is:open" => state = Some(issue::State::Open),
+
                "is:closed" => {
+
                    state = Some(issue::State::Closed {
+
                        reason: issue::CloseReason::Other,
+
                    })
+
                }
+
                "is:solved" => {
+
                    state = Some(issue::State::Closed {
+
                        reason: issue::CloseReason::Solved,
+
                    })
+
                }
+
                "is:assigned" => assigned = true,
+
                other => match assignees_parser.parse(other) {
+
                    Ok((_, dids)) => {
+
                        for did in dids {
+
                            assignees.push(Did::from_str(did)?);
+
                        }
+
                    }
+
                    _ => search.push_str(other),
+
                },
+
            }
+
        }
+

+
        Ok(Self {
+
            state,
+
            assigned,
+
            assignees,
+
            search: Some(search),
+
        })
+
    }
+
}
+

#[derive(Clone, Debug)]
pub struct PatchItem {
    /// Patch OID.
@@ -535,7 +646,7 @@ impl PatchItemFilter {
            .then(|| {
                self.authors
                    .iter()
-
                    .any(|other| patch.author.nid.unwrap() == **other)
+
                    .any(|other| patch.author.nid == Some(**other))
            })
            .unwrap_or(true);

@@ -686,4 +797,24 @@ mod tests {

        Ok(())
    }
+

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

+
        let expected = IssueItemFilter {
+
            state: Some(issue::State::Open),
+
            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(())
+
    }
}