Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
inbox: Add search widget
Merged did:key:z6MkgFq6...nBGz opened 2 years ago
12 files changed +1337 -975 a4fa4aba 3c8eada3
modified bin/commands/inbox/flux/select.rs
@@ -13,9 +13,10 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::common::cob::inbox::{self};
-
use tui::flux::store::{State, Store};
+
use tui::flux::store;
+
use tui::flux::store::StateValue;
use tui::flux::task::{self, Interrupted};
-
use tui::flux::ui::cob::NotificationItem;
+
use tui::flux::ui::items::NotificationItem;
use tui::flux::ui::Frontend;
use tui::Exit;

@@ -40,24 +41,29 @@ 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 InboxState {
+
pub struct State {
    notifications: Vec<NotificationItem>,
    selected: Option<NotificationItem>,
    mode: Mode,
    project: Project,
+
    search: StateValue<String>,
    ui: UIState,
}

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

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
@@ -151,6 +157,7 @@ impl TryFrom<&Context> for InboxState {
            selected,
            mode: mode.clone(),
            project,
+
            search: StateValue::new(String::new()),
            ui: UIState::default(),
        })
    }
@@ -160,9 +167,13 @@ pub enum Action {
    Exit { selection: Option<Selection> },
    Select { item: NotificationItem },
    PageSize(usize),
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
}

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

    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
@@ -176,6 +187,24 @@ impl State<Action, Selection> for InboxState {
                self.ui.page_size = size;
                None
            }
+
            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
+
            }
        }
    }
}
@@ -187,14 +216,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, InboxState, Selection>::new();
+
        let (store, state_rx) = store::Store::<Action, State, Selection>::new();
        let (frontend, action_rx) = Frontend::<Action>::new();
-
        let state = InboxState::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::<InboxState, 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/inbox/flux/select/ui.rs
@@ -1,11 +1,12 @@
use std::collections::HashMap;
+
use std::str::FromStr;

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;

@@ -13,9 +14,10 @@ use radicle::identity::Project;

use radicle_tui as tui;

-
use tui::flux::ui::cob::NotificationItem;
+
use tui::flux::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
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,
};
@@ -23,18 +25,20 @@ use tui::Selection;

use crate::tui_inbox::common::{Mode, RepositoryMode, SelectionMode};

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

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

-
impl From<&InboxState> for ListPageProps {
-
    fn from(state: &InboxState) -> 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,
        }
    }
}
@@ -46,12 +50,14 @@ pub struct ListPage {
    props: ListPageProps,
    /// Notification widget
    notifications: Notifications,
+
    /// Search widget
+
    search: Search,
    /// Shortcut widget
    shortcuts: Shortcuts<Action>,
}

-
impl Widget<InboxState, Action> for ListPage {
-
    fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self
+
impl Widget<State, Action> for ListPage {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
@@ -59,12 +65,13 @@ impl Widget<InboxState, Action> for ListPage {
            action_tx: action_tx.clone(),
            props: ListPageProps::from(state),
            notifications: Notifications::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: &InboxState) -> Self
+
    fn move_with_state(self, state: &State) -> Self
    where
        Self: Sized,
    {
@@ -81,39 +88,46 @@ impl Widget<InboxState, 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 selection = match self.props.mode.selection() {
-
                        SelectionMode::Operation => Selection::default()
-
                            .with_operation("show".to_string())
-
                            .with_id(selected.id),
-
                        SelectionMode::Id => Selection::default().with_id(selected.id),
-
                    };
-
                    let _ = self.action_tx.send(Action::Exit {
-
                        selection: Some(selection),
-
                    });
+
        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('c') => {
-
                if let Some(selected) = &self.props.selected {
-
                    let _ = self.action_tx.send(Action::Exit {
-
                        selection: Some(
-
                            Selection::default()
-
                                .with_operation("clear".to_string())
+
                Key::Char('\n') => {
+
                    if let Some(selected) = &self.props.selected {
+
                        let selection = match self.props.mode.selection() {
+
                            SelectionMode::Operation => Selection::default()
+
                                .with_operation("show".to_string())
                                .with_id(selected.id),
-
                        ),
-
                    });
+
                            SelectionMode::Id => Selection::default().with_id(selected.id),
+
                        };
+
                        let _ = self.action_tx.send(Action::Exit {
+
                            selection: Some(selection),
+
                        });
+
                    }
+
                }
+
                Key::Char('c') => {
+
                    if let Some(selected) = &self.props.selected {
+
                        let _ = self.action_tx.send(Action::Exit {
+
                            selection: Some(
+
                                Selection::default()
+
                                    .with_operation("clear".to_string())
+
                                    .with_id(selected.id),
+
                            ),
+
                        });
+
                    }
+
                }
+
                Key::Char('/') => {
+
                    let _ = self.action_tx.send(Action::OpenSearch);
+
                }
+
                _ => {
+
                    <Notifications as Widget<State, Action>>::handle_key_event(
+
                        &mut self.notifications,
+
                        key,
+
                    );
                }
-
            }
-
            _ => {
-
                <Notifications as Widget<InboxState, Action>>::handle_key_event(
-
                    &mut self.notifications,
-
                    key,
-
                );
            }
        }
    }
@@ -124,14 +138,37 @@ 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.selection() {
-
            SelectionMode::Id => vec![Shortcut::new("enter", "select")],
-
            SelectionMode::Operation => {
-
                vec![Shortcut::new("enter", "show"), Shortcut::new("c", "clear")]
+
        let shortcuts = if self.props.show_search {
+
            vec![
+
                Shortcut::new("esc", "back"),
+
                Shortcut::new("enter", "search"),
+
            ]
+
        } else {
+
            match self.props.mode.selection() {
+
                SelectionMode::Id => vec![
+
                    Shortcut::new("enter", "select"),
+
                    Shortcut::new("/", "search"),
+
                ],
+
                SelectionMode::Operation => vec![
+
                    Shortcut::new("enter", "show"),
+
                    Shortcut::new("c", "clear"),
+
                    Shortcut::new("/", "search"),
+
                ],
            }
        };

-
        self.notifications.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.notifications
+
                .render::<B>(frame, component_layout[0], ());
+
            self.search
+
                .render::<B>(frame, component_layout[1], SearchProps {});
+
        } else {
+
            self.notifications.render::<B>(frame, layout.component, ());
+
        }
+

        self.shortcuts.render::<B>(
            frame,
            layout.shortcuts,
@@ -152,13 +189,25 @@ struct NotificationsProps {
    cutoff_after: usize,
    focus: bool,
    page_size: usize,
+
    search: String,
+
    show_search: bool,
}

-
impl From<&InboxState> for NotificationsProps {
-
    fn from(state: &InboxState) -> Self {
+
impl From<&State> for NotificationsProps {
+
    fn from(state: &State) -> Self {
        let mut seen = 0;
        let mut unseen = 0;

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

+
        // Compute statistics
        for notification in &state.notifications {
            if notification.seen {
                seen += 1;
@@ -166,10 +215,11 @@ impl From<&InboxState> for NotificationsProps {
                unseen += 1;
            }
        }
+

        let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]);

        Self {
-
            notifications: state.notifications.clone(),
+
            notifications,
            mode: state.mode.clone(),
            project: state.project.clone(),
            stats,
@@ -177,6 +227,8 @@ impl From<&InboxState> for NotificationsProps {
            cutoff_after: 5,
            focus: false,
            page_size: state.ui.page_size,
+
            show_search: state.ui.show_search,
+
            search: state.search.read(),
        }
    }
}
@@ -194,8 +246,8 @@ struct Notifications {
    footer: Footer<Action>,
}

-
impl Widget<InboxState, Action> for Notifications {
-
    fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self {
+
impl Widget<State, Action> for Notifications {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
        Self {
            action_tx: action_tx.clone(),
            props: NotificationsProps::from(state),
@@ -205,14 +257,23 @@ impl Widget<InboxState, Action> for Notifications {
        }
    }

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

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

        Self {
-
            props: NotificationsProps::from(state),
+
            props,
+
            table,
            header: self.header.move_with_state(state),
-
            table: self.table.move_with_state(state),
            footer: self.footer.move_with_state(state),
            ..self
        }
@@ -299,7 +360,7 @@ impl Notifications {
                TableProps {
                    items: self.props.notifications.to_vec(),
                    has_header: true,
-
                    has_footer: true,
+
                    has_footer: !self.props.show_search,
                    widths,
                    focus: self.props.focus,
                    cutoff: self.props.cutoff,
@@ -324,7 +385,7 @@ impl Notifications {
                TableProps {
                    items: self.props.notifications.to_vec(),
                    has_header: true,
-
                    has_footer: true,
+
                    has_footer: !self.props.show_search,
                    widths,
                    focus: self.props.focus,
                    cutoff: self.props.cutoff,
@@ -335,7 +396,18 @@ impl Notifications {
    }

    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        let filter = Line::from([span::blank()].to_vec());
+
        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 seen = Line::from(
            [
                span::positive(self.props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
@@ -358,50 +430,153 @@ impl Notifications {
            .progress_percentage(self.props.notifications.len(), self.props.page_size);
        let progress = span::default(format!("{}%", progress)).dim();

-
        self.footer.render::<B>(
-
            frame,
-
            area,
-
            FooterProps {
-
                cells: [
-
                    filter.into(),
-
                    seen.clone().into(),
-
                    unseen.clone().into(),
-
                    progress.clone().into(),
-
                ],
-
                widths: [
-
                    Constraint::Fill(1),
-
                    Constraint::Min(seen.width() as u16),
-
                    Constraint::Min(unseen.width() as u16),
-
                    Constraint::Min(4),
-
                ],
-
                focus: self.props.focus,
-
                cutoff: self.props.cutoff,
-
                cutoff_after: self.props.cutoff_after,
-
            },
-
        );
+
        match NotificationItemFilter::from_str(&self.props.search)
+
            .unwrap_or_default()
+
            .state()
+
        {
+
            Some(state) => {
+
                let block = match state {
+
                    NotificationState::Seen => seen,
+
                    NotificationState::Unseen => unseen,
+
                };
+

+
                self.footer.render::<B>(
+
                    frame,
+
                    area,
+
                    FooterProps {
+
                        cells: [search.into(), block.clone().into(), progress.clone().into()],
+
                        widths: [
+
                            Constraint::Fill(1),
+
                            Constraint::Min(block.width() as u16),
+
                            Constraint::Min(4),
+
                        ],
+
                        focus: self.props.focus,
+
                        cutoff: self.props.cutoff,
+
                        cutoff_after: self.props.cutoff_after,
+
                    },
+
                );
+
            }
+
            None => {
+
                self.footer.render::<B>(
+
                    frame,
+
                    area,
+
                    FooterProps {
+
                        cells: [
+
                            search.into(),
+
                            seen.clone().into(),
+
                            unseen.clone().into(),
+
                            progress.clone().into(),
+
                        ],
+
                        widths: [
+
                            Constraint::Fill(1),
+
                            Constraint::Min(seen.width() as u16),
+
                            Constraint::Min(unseen.width() as u16),
+
                            Constraint::Min(4),
+
                        ],
+
                        focus: self.props.focus,
+
                        cutoff: self.props.cutoff,
+
                        cutoff_after: self.props.cutoff_after,
+
                    },
+
                );
+
            }
+
        }
    }
}

impl Render<()> for Notifications {
    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 bin/commands/issue/flux/select.rs
@@ -12,7 +12,7 @@ use radicle_tui as tui;
use tui::common::cob::issue::{self, Filter};
use tui::flux::store::{self, StateValue};
use tui::flux::task::{self, Interrupted};
-
use tui::flux::ui::cob::IssueItem;
+
use tui::flux::ui::items::IssueItem;
use tui::flux::ui::Frontend;
use tui::Exit;

modified bin/commands/issue/flux/select/ui.rs
@@ -14,7 +14,7 @@ use ratatui::text::Line;

use radicle_tui as tui;

-
use tui::flux::ui::cob::{IssueItem, IssueItemFilter};
+
use tui::flux::ui::items::{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};
modified bin/commands/patch/flux/select.rs
@@ -12,7 +12,7 @@ use radicle_tui as tui;
use tui::common::cob::patch::{self, Filter};
use tui::flux::store::{State, StateValue, Store};
use tui::flux::task::{self, Interrupted};
-
use tui::flux::ui::cob::PatchItem;
+
use tui::flux::ui::items::PatchItem;
use tui::flux::ui::Frontend;
use tui::Exit;

modified bin/commands/patch/flux/select/ui.rs
@@ -16,7 +16,7 @@ use ratatui::text::Line;
use radicle_tui as tui;

use tui::flux::store::StateValue;
-
use tui::flux::ui::cob::{PatchItem, PatchItemFilter};
+
use tui::flux::ui::items::{PatchItem, PatchItemFilter};
use tui::flux::ui::span;
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
use tui::flux::ui::widget::input::{TextField, TextFieldProps};
modified src/common/cob.rs
@@ -5,7 +5,6 @@ use anyhow::Result;
use radicle::cob::Label;
use radicle::prelude::Did;

-
pub mod format;
pub mod inbox;
pub mod issue;
pub mod patch;
deleted src/common/cob/format.rs
@@ -1,7 +0,0 @@
-
use radicle::identity::Did;
-

-
/// Format a DID.
-
pub fn did(did: &Did) -> String {
-
    let nid = did.as_key().to_human();
-
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
-
}
modified src/flux/ui.rs
@@ -1,6 +1,6 @@
-
pub mod cob;
pub mod ext;
pub mod format;
+
pub mod items;
pub mod layout;
pub mod span;
pub mod theme;
deleted src/flux/ui/cob.rs
@@ -1,867 +0,0 @@
-
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::{Label, ObjectId, Timestamp, TypedId};
-
use radicle::crypto::PublicKey;
-
use radicle::git::Oid;
-
use radicle::identity::{Did, Identity};
-
use radicle::issue::{self, CloseReason, Issue, IssueId, Issues};
-
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
-
use radicle::node::{Alias, AliasStore, NodeId};
-
use radicle::patch;
-
use radicle::patch::{Patch, PatchId, Patches};
-
use radicle::storage::git::Repository;
-
use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, WriteRepository};
-
use radicle::Profile;
-

-
use ratatui::style::{Color, Style, Stylize};
-
use ratatui::widgets::Cell;
-

-
use super::super::git;
-
use super::theme::style;
-
use super::widget::ToRow;
-
use super::{format, span};
-

-
#[derive(Clone, Debug, PartialEq, Eq)]
-
pub struct AuthorItem {
-
    pub nid: Option<NodeId>,
-
    pub alias: Option<Alias>,
-
    pub you: bool,
-
}
-

-
impl AuthorItem {
-
    pub fn new(nid: Option<NodeId>, profile: &Profile) -> Self {
-
        let alias = match nid {
-
            Some(nid) => profile.alias(&nid),
-
            None => None,
-
        };
-
        let you = nid.map(|nid| nid == *profile.id()).unwrap_or_default();
-

-
        Self { nid, alias, you }
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum NotificationKindItem {
-
    Branch {
-
        name: String,
-
        summary: String,
-
        status: String,
-
        id: Option<ObjectId>,
-
    },
-
    Cob {
-
        type_name: String,
-
        summary: String,
-
        status: String,
-
        id: Option<ObjectId>,
-
    },
-
    Unknown {
-
        refname: String,
-
    },
-
}
-

-
impl NotificationKindItem {
-
    pub fn new(
-
        repo: &Repository,
-
        notification: &Notification,
-
    ) -> Result<Option<Self>, anyhow::Error> {
-
        // TODO: move out of here
-
        let issues = Issues::open(repo)?;
-
        let patches = Patches::open(repo)?;
-

-
        match &notification.kind {
-
            NotificationKind::Branch { name } => {
-
                let (head, message) = if let Some(head) = notification.update.new() {
-
                    let message = repo.commit(head)?.summary().unwrap_or_default().to_owned();
-
                    (Some(head), message)
-
                } else {
-
                    (None, String::new())
-
                };
-
                let status = match notification
-
                    .update
-
                    .new()
-
                    .map(|oid| repo.is_ancestor_of(oid, head.unwrap()))
-
                    .transpose()
-
                {
-
                    Ok(Some(true)) => "merged",
-
                    Ok(Some(false)) | Ok(None) => match notification.update {
-
                        RefUpdate::Updated { .. } => "updated",
-
                        RefUpdate::Created { .. } => "created",
-
                        RefUpdate::Deleted { .. } => "deleted",
-
                        RefUpdate::Skipped { .. } => "skipped",
-
                    },
-
                    Err(e) => return Err(e.into()),
-
                }
-
                .to_owned();
-

-
                Ok(Some(NotificationKindItem::Branch {
-
                    name: name.to_string(),
-
                    summary: message,
-
                    status: status.to_string(),
-
                    id: head.map(ObjectId::from),
-
                }))
-
            }
-
            NotificationKind::Cob { typed_id } => {
-
                let TypedId { id, .. } = typed_id;
-
                let (category, summary, state) = if typed_id.is_issue() {
-
                    let Some(issue) = issues.get(id)? else {
-
                        // Issue could have been deleted after notification was created.
-
                        return Ok(None);
-
                    };
-
                    (
-
                        String::from("issue"),
-
                        issue.title().to_owned(),
-
                        issue.state().to_string(),
-
                    )
-
                } else if typed_id.is_patch() {
-
                    let Some(patch) = patches.get(id)? else {
-
                        // Patch could have been deleted after notification was created.
-
                        return Ok(None);
-
                    };
-
                    (
-
                        String::from("patch"),
-
                        patch.title().to_owned(),
-
                        patch.state().to_string(),
-
                    )
-
                } else if typed_id.is_identity() {
-
                    let Ok(identity) = Identity::get(id, repo) else {
-
                        log::error!(
-
                            target: "cli",
-
                            "Error retrieving identity {id} for notification {}", notification.id
-
                        );
-
                        return Ok(None);
-
                    };
-
                    let Some(rev) = notification
-
                        .update
-
                        .new()
-
                        .and_then(|id| identity.revision(&id))
-
                    else {
-
                        log::error!(
-
                            target: "cli",
-
                            "Error retrieving identity revision for notification {}", notification.id
-
                        );
-
                        return Ok(None);
-
                    };
-
                    (String::from("id"), rev.title.clone(), rev.state.to_string())
-
                } else {
-
                    (typed_id.type_name.to_string(), "".to_owned(), String::new())
-
                };
-

-
                Ok(Some(NotificationKindItem::Cob {
-
                    type_name: category.to_string(),
-
                    summary: summary.to_string(),
-
                    status: state.to_string(),
-
                    id: Some(*id),
-
                }))
-
            }
-
            NotificationKind::Unknown { refname } => Ok(Some(NotificationKindItem::Unknown {
-
                refname: refname.to_string(),
-
            })),
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct NotificationItem {
-
    /// Unique notification ID.
-
    pub id: NotificationId,
-
    /// The project this belongs to.
-
    pub project: String,
-
    /// Mark this notification as seen.
-
    pub seen: bool,
-
    /// Wrapped notification kind.
-
    pub kind: NotificationKindItem,
-
    /// The author
-
    pub author: AuthorItem,
-
    /// Time the update has happened.
-
    pub timestamp: Timestamp,
-
}
-

-
impl NotificationItem {
-
    pub fn new(
-
        profile: &Profile,
-
        repo: &Repository,
-
        notification: &Notification,
-
    ) -> Result<Option<Self>, anyhow::Error> {
-
        let project = profile
-
            .storage
-
            .repository(repo.id)?
-
            .identity_doc()?
-
            .project()?;
-
        let name = project.name().to_string();
-
        let kind = NotificationKindItem::new(repo, notification)?;
-

-
        if kind.is_none() {
-
            return Ok(None);
-
        }
-

-
        Ok(Some(NotificationItem {
-
            id: notification.id,
-
            project: name,
-
            seen: notification.status.is_read(),
-
            kind: kind.unwrap(),
-
            author: AuthorItem::new(notification.remote, profile),
-
            timestamp: notification.timestamp.into(),
-
        }))
-
    }
-
}
-

-
impl ToRow<8> for NotificationItem {
-
    fn to_row(&self) -> [Cell; 8] {
-
        let (type_name, summary, status, kind_id) = match &self.kind {
-
            NotificationKindItem::Branch {
-
                name,
-
                summary,
-
                status,
-
                id: _,
-
            } => (
-
                "branch".to_string(),
-
                summary.clone(),
-
                status.clone(),
-
                name.to_string(),
-
            ),
-
            NotificationKindItem::Cob {
-
                type_name,
-
                summary,
-
                status,
-
                id,
-
            } => {
-
                let id = id.map(|id| format::cob(&id)).unwrap_or_default();
-
                (
-
                    type_name.to_string(),
-
                    summary.clone(),
-
                    status.clone(),
-
                    id.to_string(),
-
                )
-
            }
-
            NotificationKindItem::Unknown { refname } => (
-
                refname.to_string(),
-
                String::new(),
-
                String::new(),
-
                String::new(),
-
            ),
-
        };
-

-
        let id = span::notification_id(format!(" {:-03}", &self.id));
-
        let seen = if self.seen {
-
            span::blank()
-
        } else {
-
            span::primary(" ● ".into())
-
        };
-
        let kind_id = span::primary(kind_id);
-
        let summary = span::default(summary.to_string());
-
        let type_name = span::notification_type(type_name);
-

-
        let status = match status.as_str() {
-
            "archived" => span::default(status.to_string()).yellow(),
-
            "draft" => span::default(status.to_string()).gray().dim(),
-
            "updated" => span::primary(status.to_string()),
-
            "open" | "created" => span::positive(status.to_string()),
-
            "closed" | "merged" => span::ternary(status.to_string()),
-
            _ => span::default(status.to_string()),
-
        };
-
        let author = match &self.author.alias {
-
            Some(alias) => {
-
                if self.author.you {
-
                    span::alias(format!("{} (you)", alias))
-
                } else {
-
                    span::alias(alias.to_string())
-
                }
-
            }
-
            None => match self.author.nid {
-
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
                None => span::alias("".to_string()),
-
            },
-
        };
-

-
        let timestamp = span::timestamp(format::timestamp(&self.timestamp));
-

-
        [
-
            id.into(),
-
            seen.into(),
-
            kind_id.into(),
-
            summary.into(),
-
            type_name.into(),
-
            status.into(),
-
            author.into(),
-
            timestamp.into(),
-
        ]
-
    }
-
}
-

-
impl ToRow<9> for NotificationItem {
-
    fn to_row(&self) -> [Cell; 9] {
-
        let row: [Cell; 8] = self.to_row();
-
        let name = span::default(self.project.clone()).style(style::gray().dim());
-

-
        [
-
            row[0].clone(),
-
            row[1].clone(),
-
            name.into(),
-
            row[2].clone(),
-
            row[3].clone(),
-
            row[4].clone(),
-
            row[5].clone(),
-
            row[6].clone(),
-
            row[7].clone(),
-
        ]
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct IssueItem {
-
    /// Issue OID.
-
    pub id: IssueId,
-
    /// Issue state.
-
    pub state: issue::State,
-
    /// Issue title.
-
    pub title: String,
-
    /// Issue author.
-
    pub author: AuthorItem,
-
    /// Issue labels.
-
    pub labels: Vec<Label>,
-
    /// Issue assignees.
-
    pub assignees: Vec<AuthorItem>,
-
    /// Time when issue was opened.
-
    pub timestamp: Timestamp,
-
}
-

-
impl IssueItem {
-
    pub fn new(profile: &Profile, issue: (IssueId, Issue)) -> Result<Self, anyhow::Error> {
-
        let (id, issue) = issue;
-

-
        Ok(Self {
-
            id,
-
            state: *issue.state(),
-
            title: issue.title().into(),
-
            author: AuthorItem {
-
                nid: Some(*issue.author().id),
-
                alias: profile.aliases().alias(&issue.author().id),
-
                you: *issue.author().id == *profile.did(),
-
            },
-
            labels: issue.labels().cloned().collect(),
-
            assignees: issue
-
                .assignees()
-
                .map(|did| AuthorItem {
-
                    nid: Some(**did),
-
                    alias: profile.aliases().alias(did),
-
                    you: *did == profile.did(),
-
                })
-
                .collect::<Vec<_>>(),
-
            timestamp: issue.timestamp(),
-
        })
-
    }
-
}
-

-
impl ToRow<8> for IssueItem {
-
    fn to_row(&self) -> [Cell; 8] {
-
        let (state, state_color) = format_issue_state(&self.state);
-

-
        let state = span::default(state).style(Style::default().fg(state_color));
-
        let id = span::primary(format::cob(&self.id));
-
        let title = span::default(self.title.clone());
-

-
        let author = match &self.author.alias {
-
            Some(alias) => {
-
                if self.author.you {
-
                    span::alias(format!("{} (you)", alias))
-
                } else {
-
                    span::alias(alias.to_string())
-
                }
-
            }
-
            None => match self.author.nid {
-
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
                None => span::alias("".to_string()),
-
            },
-
        };
-
        let did = match self.author.nid {
-
            Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
            None => span::alias("".to_string()),
-
        };
-
        let labels = span::labels(format_labels(&self.labels));
-
        let assignees = self
-
            .assignees
-
            .iter()
-
            .map(|author| (author.nid, author.alias.clone(), author.you))
-
            .collect::<Vec<_>>();
-
        let assignees = span::alias(format_assignees(&assignees));
-
        let opened = span::timestamp(format::timestamp(&self.timestamp));
-

-
        [
-
            state.into(),
-
            id.into(),
-
            title.into(),
-
            author.into(),
-
            did.into(),
-
            labels.into(),
-
            assignees.into(),
-
            opened.into(),
-
        ]
-
    }
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct IssueItemFilter {
-
    state: Option<issue::State>,
-
    authored: bool,
-
    authors: Vec<Did>,
-
    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_authored = if self.authored {
-
            issue.author.you
-
        } else {
-
            true
-
        };
-

-
        let matches_authors = (!self.authors.is_empty())
-
            .then(|| {
-
                self.authors
-
                    .iter()
-
                    .any(|other| issue.author.nid == Some(**other))
-
            })
-
            .unwrap_or(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_authored
-
            && matches_authors
-
            && 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 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("]"),
-
                ),
-
            )(input)
-
        };
-

-
        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: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)?);
-
                        }
-
                    } else if let Ok((_, dids)) = authors_parser.parse(other) {
-
                        for did in dids {
-
                            authors.push(Did::from_str(did)?);
-
                        }
-
                    } else {
-
                        search.push_str(other);
-
                    }
-
                }
-
            }
-
        }
-

-
        Ok(Self {
-
            state,
-
            authored,
-
            authors,
-
            assigned,
-
            assignees,
-
            search: Some(search),
-
        })
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct PatchItem {
-
    /// Patch OID.
-
    pub id: PatchId,
-
    /// Patch state.
-
    pub state: patch::State,
-
    /// Patch title.
-
    pub title: String,
-
    /// Author of the latest revision.
-
    pub author: AuthorItem,
-
    /// Head of the latest revision.
-
    pub head: Oid,
-
    /// Lines added by the latest revision.
-
    pub added: u16,
-
    /// Lines removed by the latest revision.
-
    pub removed: u16,
-
    /// Time when patch was opened.
-
    pub timestamp: Timestamp,
-
}
-

-
impl PatchItem {
-
    pub fn new(
-
        profile: &Profile,
-
        repository: &Repository,
-
        patch: (PatchId, Patch),
-
    ) -> Result<Self, anyhow::Error> {
-
        let (id, patch) = patch;
-
        let (_, revision) = patch.latest();
-
        let (from, to) = revision.range();
-
        let stats = git::diff_stats(repository.raw(), &from, &to)?;
-

-
        Ok(Self {
-
            id,
-
            state: patch.state().clone(),
-
            title: patch.title().into(),
-
            author: AuthorItem {
-
                nid: Some(*patch.author().id),
-
                alias: profile.aliases().alias(&patch.author().id),
-
                you: *patch.author().id == *profile.did(),
-
            },
-
            head: revision.head(),
-
            added: stats.insertions() as u16,
-
            removed: stats.deletions() as u16,
-
            timestamp: patch.updated_at(),
-
        })
-
    }
-
}
-

-
impl ToRow<9> for PatchItem {
-
    fn to_row(&self) -> [Cell; 9] {
-
        let (state, color) = format_patch_state(&self.state);
-

-
        let state = span::default(state).style(Style::default().fg(color));
-
        let id = span::primary(format::cob(&self.id));
-
        let title = span::default(self.title.clone());
-

-
        let author = match &self.author.alias {
-
            Some(alias) => {
-
                if self.author.you {
-
                    span::alias(format!("{} (you)", alias))
-
                } else {
-
                    span::alias(alias.to_string())
-
                }
-
            }
-
            None => match self.author.nid {
-
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
                None => span::alias("".to_string()),
-
            },
-
        };
-
        let did = match self.author.nid {
-
            Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
            None => span::alias("".to_string()),
-
        };
-

-
        let head = span::ternary(format::oid(self.head));
-
        let added = span::positive(format!("+{}", self.added));
-
        let removed = span::negative(format!("-{}", self.removed));
-
        let updated = span::timestamp(format::timestamp(&self.timestamp));
-

-
        [
-
            state.into(),
-
            id.into(),
-
            title.into(),
-
            author.into(),
-
            did.into(),
-
            head.into(),
-
            added.into(),
-
            removed.into(),
-
            updated.into(),
-
        ]
-
    }
-
}
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct PatchItemFilter {
-
    status: Option<patch::Status>,
-
    authored: bool,
-
    authors: Vec<Did>,
-
    search: Option<String>,
-
}
-

-
impl PatchItemFilter {
-
    pub fn status(&self) -> Option<patch::Status> {
-
        self.status
-
    }
-

-
    pub fn matches(&self, patch: &PatchItem) -> bool {
-
        use fuzzy_matcher::skim::SkimMatcherV2;
-
        use fuzzy_matcher::FuzzyMatcher;
-

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

-
        let matches_state = match self.status {
-
            Some(patch::Status::Draft) => matches!(patch.state, patch::State::Draft),
-
            Some(patch::Status::Open) => matches!(patch.state, patch::State::Open { .. }),
-
            Some(patch::Status::Merged) => matches!(patch.state, patch::State::Merged { .. }),
-
            Some(patch::Status::Archived) => matches!(patch.state, patch::State::Archived),
-
            None => true,
-
        };
-

-
        let matches_authored = if self.authored {
-
            patch.author.you
-
        } else {
-
            true
-
        };
-

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

-
        let matches_search = match &self.search {
-
            Some(search) => match matcher.fuzzy_match(&patch.title, search) {
-
                Some(score) => score == 0 || score > 60,
-
                _ => false,
-
            },
-
            None => true,
-
        };
-

-
        matches_state && matches_authored && matches_authors && matches_search
-
    }
-
}
-

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

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

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

-
        let parts = value.split(' ');
-
        for part in parts {
-
            match part {
-
                "is:open" => status = Some(patch::Status::Open),
-
                "is:merged" => status = Some(patch::Status::Merged),
-
                "is:archived" => status = Some(patch::Status::Archived),
-
                "is:draft" => status = Some(patch::Status::Draft),
-
                "is:authored" => authored = true,
-
                other => match authors_parser.parse(other) {
-
                    Ok((_, dids)) => {
-
                        for did in dids {
-
                            authors.push(Did::from_str(did)?);
-
                        }
-
                    }
-
                    _ => search.push_str(other),
-
                },
-
            }
-
        }
-

-
        Ok(Self {
-
            status,
-
            authored,
-
            authors,
-
            search: Some(search),
-
        })
-
    }
-
}
-

-
pub fn format_issue_state(state: &issue::State) -> (String, Color) {
-
    match state {
-
        issue::State::Open => (" ● ".into(), Color::Green),
-
        issue::State::Closed { reason: _ } => (" ● ".into(), Color::Red),
-
    }
-
}
-

-
pub fn format_patch_state(state: &patch::State) -> (String, Color) {
-
    match state {
-
        patch::State::Open { conflicts: _ } => (" ● ".into(), Color::Green),
-
        patch::State::Archived => (" ● ".into(), Color::Yellow),
-
        patch::State::Draft => (" ● ".into(), Color::Gray),
-
        patch::State::Merged {
-
            revision: _,
-
            commit: _,
-
        } => (" ✔ ".into(), Color::Magenta),
-
    }
-
}
-

-
pub fn format_labels(labels: &[Label]) -> String {
-
    let mut output = String::new();
-
    let mut labels = labels.iter().peekable();
-

-
    while let Some(label) = labels.next() {
-
        output.push_str(&label.to_string());
-

-
        if labels.peek().is_some() {
-
            output.push(',');
-
        }
-
    }
-
    output
-
}
-

-
pub fn format_author(did: &Did, alias: &Option<Alias>, is_you: bool) -> String {
-
    let author = match alias {
-
        Some(alias) => format!("{alias}"),
-
        None => format::did(did),
-
    };
-

-
    if is_you {
-
        format!("{} (you)", author)
-
    } else {
-
        author
-
    }
-
}
-

-
pub fn format_assignees(assignees: &[(Option<PublicKey>, Option<Alias>, bool)]) -> String {
-
    let mut output = String::new();
-
    let mut assignees = assignees.iter().peekable();
-

-
    while let Some((assignee, alias, is_you)) = assignees.next() {
-
        if let Some(assignee) = assignee {
-
            output.push_str(&format_author(&Did::from(assignee), alias, *is_you));
-
        }
-

-
        if assignees.peek().is_some() {
-
            output.push(',');
-
        }
-
    }
-
    output
-
}
-

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

-
    use super::*;
-

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

-
        let expected = PatchItemFilter {
-
            status: Some(patch::Status::Open),
-
            authored: true,
-
            authors: vec![
-
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
-
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
-
            ],
-
            search: Some("cli".to_string()),
-
        };
-

-
        assert_eq!(expected, actual);
-

-
        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] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
-
        let actual = IssueItemFilter::from_str(search)?;
-

-
        let expected = IssueItemFilter {
-
            state: Some(issue::State::Open),
-
            authors: vec![
-
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
-
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
-
            ],
-
            authored: false,
-
            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 src/flux/ui/format.rs
@@ -1,5 +1,11 @@
+
use radicle::cob::Label;
use radicle::cob::{ObjectId, Timestamp};
+
use radicle::crypto::PublicKey;
+
use radicle::issue;
+
use radicle::node::Alias;
+
use radicle::patch;
use radicle::prelude::Did;
+
use ratatui::style::Color;

/// Format a git Oid.
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
@@ -25,3 +31,65 @@ pub fn timestamp(time: &Timestamp) -> String {

    fmt.convert(duration)
}
+

+
pub fn issue_state(state: &issue::State) -> (String, Color) {
+
    match state {
+
        issue::State::Open => (" ● ".into(), Color::Green),
+
        issue::State::Closed { reason: _ } => (" ● ".into(), Color::Red),
+
    }
+
}
+

+
pub fn patch_state(state: &patch::State) -> (String, Color) {
+
    match state {
+
        patch::State::Open { conflicts: _ } => (" ● ".into(), Color::Green),
+
        patch::State::Archived => (" ● ".into(), Color::Yellow),
+
        patch::State::Draft => (" ● ".into(), Color::Gray),
+
        patch::State::Merged {
+
            revision: _,
+
            commit: _,
+
        } => (" ✔ ".into(), Color::Magenta),
+
    }
+
}
+

+
pub fn labels(labels: &[Label]) -> String {
+
    let mut output = String::new();
+
    let mut labels = labels.iter().peekable();
+

+
    while let Some(label) = labels.next() {
+
        output.push_str(&label.to_string());
+

+
        if labels.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
+

+
pub fn author(did: &Did, alias: &Option<Alias>, is_you: bool) -> String {
+
    let author = match alias {
+
        Some(alias) => format!("{alias}"),
+
        None => self::did(did),
+
    };
+

+
    if is_you {
+
        format!("{} (you)", author)
+
    } else {
+
        author
+
    }
+
}
+

+
pub fn assignees(assignees: &[(Option<PublicKey>, Option<Alias>, bool)]) -> String {
+
    let mut output = String::new();
+
    let mut assignees = assignees.iter().peekable();
+

+
    while let Some((assignee, alias, is_you)) = assignees.next() {
+
        if let Some(assignee) = assignee {
+
            output.push_str(&self::author(&Did::from(assignee), alias, *is_you));
+
        }
+

+
        if assignees.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
added src/flux/ui/items.rs
@@ -0,0 +1,966 @@
+
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::{Label, ObjectId, Timestamp, TypedId};
+
use radicle::git::Oid;
+
use radicle::identity::{Did, Identity};
+
use radicle::issue::{self, CloseReason, Issue, IssueId, Issues};
+
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
+
use radicle::node::{Alias, AliasStore, NodeId};
+
use radicle::patch;
+
use radicle::patch::{Patch, PatchId, Patches};
+
use radicle::storage::git::Repository;
+
use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, WriteRepository};
+
use radicle::Profile;
+

+
use ratatui::style::{Style, Stylize};
+
use ratatui::widgets::Cell;
+

+
use super::super::git;
+
use super::theme::style;
+
use super::widget::ToRow;
+
use super::{format, span};
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct AuthorItem {
+
    pub nid: Option<NodeId>,
+
    pub alias: Option<Alias>,
+
    pub you: bool,
+
}
+

+
impl AuthorItem {
+
    pub fn new(nid: Option<NodeId>, profile: &Profile) -> Self {
+
        let alias = match nid {
+
            Some(nid) => profile.alias(&nid),
+
            None => None,
+
        };
+
        let you = nid.map(|nid| nid == *profile.id()).unwrap_or_default();
+

+
        Self { nid, alias, you }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum NotificationKindItem {
+
    Branch {
+
        name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
    Cob {
+
        type_name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
    Unknown {
+
        refname: String,
+
    },
+
}
+

+
impl NotificationKindItem {
+
    pub fn new(
+
        repo: &Repository,
+
        notification: &Notification,
+
    ) -> Result<Option<Self>, anyhow::Error> {
+
        // TODO: move out of here
+
        let issues = Issues::open(repo)?;
+
        let patches = Patches::open(repo)?;
+

+
        match &notification.kind {
+
            NotificationKind::Branch { name } => {
+
                let (head, message) = if let Some(head) = notification.update.new() {
+
                    let message = repo.commit(head)?.summary().unwrap_or_default().to_owned();
+
                    (Some(head), message)
+
                } else {
+
                    (None, String::new())
+
                };
+
                let status = match notification
+
                    .update
+
                    .new()
+
                    .map(|oid| repo.is_ancestor_of(oid, head.unwrap()))
+
                    .transpose()
+
                {
+
                    Ok(Some(true)) => "merged",
+
                    Ok(Some(false)) | Ok(None) => match notification.update {
+
                        RefUpdate::Updated { .. } => "updated",
+
                        RefUpdate::Created { .. } => "created",
+
                        RefUpdate::Deleted { .. } => "deleted",
+
                        RefUpdate::Skipped { .. } => "skipped",
+
                    },
+
                    Err(e) => return Err(e.into()),
+
                }
+
                .to_owned();
+

+
                Ok(Some(NotificationKindItem::Branch {
+
                    name: name.to_string(),
+
                    summary: message,
+
                    status: status.to_string(),
+
                    id: head.map(ObjectId::from),
+
                }))
+
            }
+
            NotificationKind::Cob { typed_id } => {
+
                let TypedId { id, .. } = typed_id;
+
                let (category, summary, state) = if typed_id.is_issue() {
+
                    let Some(issue) = issues.get(id)? else {
+
                        // Issue could have been deleted after notification was created.
+
                        return Ok(None);
+
                    };
+
                    (
+
                        String::from("issue"),
+
                        issue.title().to_owned(),
+
                        issue.state().to_string(),
+
                    )
+
                } else if typed_id.is_patch() {
+
                    let Some(patch) = patches.get(id)? else {
+
                        // Patch could have been deleted after notification was created.
+
                        return Ok(None);
+
                    };
+
                    (
+
                        String::from("patch"),
+
                        patch.title().to_owned(),
+
                        patch.state().to_string(),
+
                    )
+
                } else if typed_id.is_identity() {
+
                    let Ok(identity) = Identity::get(id, repo) else {
+
                        log::error!(
+
                            target: "cli",
+
                            "Error retrieving identity {id} for notification {}", notification.id
+
                        );
+
                        return Ok(None);
+
                    };
+
                    let Some(rev) = notification
+
                        .update
+
                        .new()
+
                        .and_then(|id| identity.revision(&id))
+
                    else {
+
                        log::error!(
+
                            target: "cli",
+
                            "Error retrieving identity revision for notification {}", notification.id
+
                        );
+
                        return Ok(None);
+
                    };
+
                    (String::from("id"), rev.title.clone(), rev.state.to_string())
+
                } else {
+
                    (typed_id.type_name.to_string(), "".to_owned(), String::new())
+
                };
+

+
                Ok(Some(NotificationKindItem::Cob {
+
                    type_name: category.to_string(),
+
                    summary: summary.to_string(),
+
                    status: state.to_string(),
+
                    id: Some(*id),
+
                }))
+
            }
+
            NotificationKind::Unknown { refname } => Ok(Some(NotificationKindItem::Unknown {
+
                refname: refname.to_string(),
+
            })),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct NotificationItem {
+
    /// Unique notification ID.
+
    pub id: NotificationId,
+
    /// The project this belongs to.
+
    pub project: String,
+
    /// Mark this notification as seen.
+
    pub seen: bool,
+
    /// Wrapped notification kind.
+
    pub kind: NotificationKindItem,
+
    /// The author
+
    pub author: AuthorItem,
+
    /// Time the update has happened.
+
    pub timestamp: Timestamp,
+
}
+

+
impl NotificationItem {
+
    pub fn new(
+
        profile: &Profile,
+
        repo: &Repository,
+
        notification: &Notification,
+
    ) -> Result<Option<Self>, anyhow::Error> {
+
        let project = profile
+
            .storage
+
            .repository(repo.id)?
+
            .identity_doc()?
+
            .project()?;
+
        let name = project.name().to_string();
+
        let kind = NotificationKindItem::new(repo, notification)?;
+

+
        if kind.is_none() {
+
            return Ok(None);
+
        }
+

+
        Ok(Some(NotificationItem {
+
            id: notification.id,
+
            project: name,
+
            seen: notification.status.is_read(),
+
            kind: kind.unwrap(),
+
            author: AuthorItem::new(notification.remote, profile),
+
            timestamp: notification.timestamp.into(),
+
        }))
+
    }
+
}
+

+
impl ToRow<8> for NotificationItem {
+
    fn to_row(&self) -> [Cell; 8] {
+
        let (type_name, summary, status, kind_id) = match &self.kind {
+
            NotificationKindItem::Branch {
+
                name,
+
                summary,
+
                status,
+
                id: _,
+
            } => (
+
                "branch".to_string(),
+
                summary.clone(),
+
                status.clone(),
+
                name.to_string(),
+
            ),
+
            NotificationKindItem::Cob {
+
                type_name,
+
                summary,
+
                status,
+
                id,
+
            } => {
+
                let id = id.map(|id| format::cob(&id)).unwrap_or_default();
+
                (
+
                    type_name.to_string(),
+
                    summary.clone(),
+
                    status.clone(),
+
                    id.to_string(),
+
                )
+
            }
+
            NotificationKindItem::Unknown { refname } => (
+
                refname.to_string(),
+
                String::new(),
+
                String::new(),
+
                String::new(),
+
            ),
+
        };
+

+
        let id = span::notification_id(format!(" {:-03}", &self.id));
+
        let seen = if self.seen {
+
            span::blank()
+
        } else {
+
            span::primary(" ● ".into())
+
        };
+
        let kind_id = span::primary(kind_id);
+
        let summary = span::default(summary.to_string());
+
        let type_name = span::notification_type(type_name);
+

+
        let status = match status.as_str() {
+
            "archived" => span::default(status.to_string()).yellow(),
+
            "draft" => span::default(status.to_string()).gray().dim(),
+
            "updated" => span::primary(status.to_string()),
+
            "open" | "created" => span::positive(status.to_string()),
+
            "closed" | "merged" => span::ternary(status.to_string()),
+
            _ => span::default(status.to_string()),
+
        };
+
        let author = match &self.author.alias {
+
            Some(alias) => {
+
                if self.author.you {
+
                    span::alias(format!("{} (you)", alias))
+
                } else {
+
                    span::alias(alias.to_string())
+
                }
+
            }
+
            None => match self.author.nid {
+
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
                None => span::alias("".to_string()),
+
            },
+
        };
+

+
        let timestamp = span::timestamp(format::timestamp(&self.timestamp));
+

+
        [
+
            id.into(),
+
            seen.into(),
+
            kind_id.into(),
+
            summary.into(),
+
            type_name.into(),
+
            status.into(),
+
            author.into(),
+
            timestamp.into(),
+
        ]
+
    }
+
}
+

+
impl ToRow<9> for NotificationItem {
+
    fn to_row(&self) -> [Cell; 9] {
+
        let row: [Cell; 8] = self.to_row();
+
        let name = span::default(self.project.clone()).style(style::gray().dim());
+

+
        [
+
            row[0].clone(),
+
            row[1].clone(),
+
            name.into(),
+
            row[2].clone(),
+
            row[3].clone(),
+
            row[4].clone(),
+
            row[5].clone(),
+
            row[6].clone(),
+
            row[7].clone(),
+
        ]
+
    }
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum NotificationType {
+
    Patch,
+
    Issue,
+
    Branch,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum NotificationState {
+
    Seen,
+
    Unseen,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct NotificationItemFilter {
+
    state: Option<NotificationState>,
+
    type_name: Option<NotificationType>,
+
    authors: Vec<Did>,
+
    search: Option<String>,
+
}
+

+
impl NotificationItemFilter {
+
    pub fn state(&self) -> Option<NotificationState> {
+
        self.state.clone()
+
    }
+

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

+
        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) => {
+
                matches!(notif.kind, NotificationKindItem::Branch { .. })
+
            }
+
            None => true,
+
        };
+

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

+
        let matches_search = match &self.search {
+
            Some(search) => {
+
                let summary = match &notif.kind {
+
                    NotificationKindItem::Cob {
+
                        type_name: _,
+
                        summary,
+
                        status: _,
+
                        id: _,
+
                    } => summary,
+
                    NotificationKindItem::Branch {
+
                        name: _,
+
                        summary,
+
                        status: _,
+
                        id: _,
+
                    } => summary,
+
                    NotificationKindItem::Unknown { refname: _ } => "",
+
                };
+
                match matcher.fuzzy_match(summary, search) {
+
                    Some(score) => score == 0 || score > 60,
+
                    _ => false,
+
                }
+
            }
+
            None => true,
+
        };
+

+
        matches_state && matches_type && matches_authors && matches_search
+
    }
+
}
+

+
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![];
+

+
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("authors:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )(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)?);
+
                        }
+
                    } else {
+
                        search.push_str(other);
+
                    }
+
                }
+
            }
+
        }
+

+
        Ok(Self {
+
            state,
+
            type_name,
+
            authors,
+
            search: Some(search),
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct IssueItem {
+
    /// Issue OID.
+
    pub id: IssueId,
+
    /// Issue state.
+
    pub state: issue::State,
+
    /// Issue title.
+
    pub title: String,
+
    /// Issue author.
+
    pub author: AuthorItem,
+
    /// Issue labels.
+
    pub labels: Vec<Label>,
+
    /// Issue assignees.
+
    pub assignees: Vec<AuthorItem>,
+
    /// Time when issue was opened.
+
    pub timestamp: Timestamp,
+
}
+

+
impl IssueItem {
+
    pub fn new(profile: &Profile, issue: (IssueId, Issue)) -> Result<Self, anyhow::Error> {
+
        let (id, issue) = issue;
+

+
        Ok(Self {
+
            id,
+
            state: *issue.state(),
+
            title: issue.title().into(),
+
            author: AuthorItem {
+
                nid: Some(*issue.author().id),
+
                alias: profile.aliases().alias(&issue.author().id),
+
                you: *issue.author().id == *profile.did(),
+
            },
+
            labels: issue.labels().cloned().collect(),
+
            assignees: issue
+
                .assignees()
+
                .map(|did| AuthorItem {
+
                    nid: Some(**did),
+
                    alias: profile.aliases().alias(did),
+
                    you: *did == profile.did(),
+
                })
+
                .collect::<Vec<_>>(),
+
            timestamp: issue.timestamp(),
+
        })
+
    }
+
}
+

+
impl ToRow<8> for IssueItem {
+
    fn to_row(&self) -> [Cell; 8] {
+
        let (state, state_color) = format::issue_state(&self.state);
+

+
        let state = span::default(state).style(Style::default().fg(state_color));
+
        let id = span::primary(format::cob(&self.id));
+
        let title = span::default(self.title.clone());
+

+
        let author = match &self.author.alias {
+
            Some(alias) => {
+
                if self.author.you {
+
                    span::alias(format!("{} (you)", alias))
+
                } else {
+
                    span::alias(alias.to_string())
+
                }
+
            }
+
            None => match self.author.nid {
+
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
                None => span::alias("".to_string()),
+
            },
+
        };
+
        let did = match self.author.nid {
+
            Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
            None => span::alias("".to_string()),
+
        };
+
        let labels = span::labels(format::labels(&self.labels));
+
        let assignees = self
+
            .assignees
+
            .iter()
+
            .map(|author| (author.nid, author.alias.clone(), author.you))
+
            .collect::<Vec<_>>();
+
        let assignees = span::alias(format::assignees(&assignees));
+
        let opened = span::timestamp(format::timestamp(&self.timestamp));
+

+
        [
+
            state.into(),
+
            id.into(),
+
            title.into(),
+
            author.into(),
+
            did.into(),
+
            labels.into(),
+
            assignees.into(),
+
            opened.into(),
+
        ]
+
    }
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct IssueItemFilter {
+
    state: Option<issue::State>,
+
    authored: bool,
+
    authors: Vec<Did>,
+
    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_authored = if self.authored {
+
            issue.author.you
+
        } else {
+
            true
+
        };
+

+
        let matches_authors = (!self.authors.is_empty())
+
            .then(|| {
+
                self.authors
+
                    .iter()
+
                    .any(|other| issue.author.nid == Some(**other))
+
            })
+
            .unwrap_or(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_authored
+
            && matches_authors
+
            && 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 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("]"),
+
                ),
+
            )(input)
+
        };
+

+
        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: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)?);
+
                        }
+
                    } else if let Ok((_, dids)) = authors_parser.parse(other) {
+
                        for did in dids {
+
                            authors.push(Did::from_str(did)?);
+
                        }
+
                    } else {
+
                        search.push_str(other);
+
                    }
+
                }
+
            }
+
        }
+

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

+
#[derive(Clone, Debug)]
+
pub struct PatchItem {
+
    /// Patch OID.
+
    pub id: PatchId,
+
    /// Patch state.
+
    pub state: patch::State,
+
    /// Patch title.
+
    pub title: String,
+
    /// Author of the latest revision.
+
    pub author: AuthorItem,
+
    /// Head of the latest revision.
+
    pub head: Oid,
+
    /// Lines added by the latest revision.
+
    pub added: u16,
+
    /// Lines removed by the latest revision.
+
    pub removed: u16,
+
    /// Time when patch was opened.
+
    pub timestamp: Timestamp,
+
}
+

+
impl PatchItem {
+
    pub fn new(
+
        profile: &Profile,
+
        repository: &Repository,
+
        patch: (PatchId, Patch),
+
    ) -> Result<Self, anyhow::Error> {
+
        let (id, patch) = patch;
+
        let (_, revision) = patch.latest();
+
        let (from, to) = revision.range();
+
        let stats = git::diff_stats(repository.raw(), &from, &to)?;
+

+
        Ok(Self {
+
            id,
+
            state: patch.state().clone(),
+
            title: patch.title().into(),
+
            author: AuthorItem {
+
                nid: Some(*patch.author().id),
+
                alias: profile.aliases().alias(&patch.author().id),
+
                you: *patch.author().id == *profile.did(),
+
            },
+
            head: revision.head(),
+
            added: stats.insertions() as u16,
+
            removed: stats.deletions() as u16,
+
            timestamp: patch.updated_at(),
+
        })
+
    }
+
}
+

+
impl ToRow<9> for PatchItem {
+
    fn to_row(&self) -> [Cell; 9] {
+
        let (state, color) = format::patch_state(&self.state);
+

+
        let state = span::default(state).style(Style::default().fg(color));
+
        let id = span::primary(format::cob(&self.id));
+
        let title = span::default(self.title.clone());
+

+
        let author = match &self.author.alias {
+
            Some(alias) => {
+
                if self.author.you {
+
                    span::alias(format!("{} (you)", alias))
+
                } else {
+
                    span::alias(alias.to_string())
+
                }
+
            }
+
            None => match self.author.nid {
+
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
                None => span::alias("".to_string()),
+
            },
+
        };
+
        let did = match self.author.nid {
+
            Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
+
            None => span::alias("".to_string()),
+
        };
+

+
        let head = span::ternary(format::oid(self.head));
+
        let added = span::positive(format!("+{}", self.added));
+
        let removed = span::negative(format!("-{}", self.removed));
+
        let updated = span::timestamp(format::timestamp(&self.timestamp));
+

+
        [
+
            state.into(),
+
            id.into(),
+
            title.into(),
+
            author.into(),
+
            did.into(),
+
            head.into(),
+
            added.into(),
+
            removed.into(),
+
            updated.into(),
+
        ]
+
    }
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct PatchItemFilter {
+
    status: Option<patch::Status>,
+
    authored: bool,
+
    authors: Vec<Did>,
+
    search: Option<String>,
+
}
+

+
impl PatchItemFilter {
+
    pub fn status(&self) -> Option<patch::Status> {
+
        self.status
+
    }
+

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

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

+
        let matches_state = match self.status {
+
            Some(patch::Status::Draft) => matches!(patch.state, patch::State::Draft),
+
            Some(patch::Status::Open) => matches!(patch.state, patch::State::Open { .. }),
+
            Some(patch::Status::Merged) => matches!(patch.state, patch::State::Merged { .. }),
+
            Some(patch::Status::Archived) => matches!(patch.state, patch::State::Archived),
+
            None => true,
+
        };
+

+
        let matches_authored = if self.authored {
+
            patch.author.you
+
        } else {
+
            true
+
        };
+

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

+
        let matches_search = match &self.search {
+
            Some(search) => match matcher.fuzzy_match(&patch.title, search) {
+
                Some(score) => score == 0 || score > 60,
+
                _ => false,
+
            },
+
            None => true,
+
        };
+

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

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

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

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

+
        let parts = value.split(' ');
+
        for part in parts {
+
            match part {
+
                "is:open" => status = Some(patch::Status::Open),
+
                "is:merged" => status = Some(patch::Status::Merged),
+
                "is:archived" => status = Some(patch::Status::Archived),
+
                "is:draft" => status = Some(patch::Status::Draft),
+
                "is:authored" => authored = true,
+
                other => match authors_parser.parse(other) {
+
                    Ok((_, dids)) => {
+
                        for did in dids {
+
                            authors.push(Did::from_str(did)?);
+
                        }
+
                    }
+
                    _ => search.push_str(other),
+
                },
+
            }
+
        }
+

+
        Ok(Self {
+
            status,
+
            authored,
+
            authors,
+
            search: Some(search),
+
        })
+
    }
+
}
+

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

+
    use super::*;
+

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

+
        let expected = PatchItemFilter {
+
            status: Some(patch::Status::Open),
+
            authored: true,
+
            authors: vec![
+
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
+
            ],
+
            search: Some("cli".to_string()),
+
        };
+

+
        assert_eq!(expected, actual);
+

+
        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] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
+
        let actual = IssueItemFilter::from_str(search)?;
+

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

+
    #[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"#;
+
        let actual = NotificationItemFilter::from_str(search)?;
+

+
        let expected = NotificationItemFilter {
+
            state: Some(NotificationState::Seen),
+
            type_name: Some(NotificationType::Patch),
+
            authors: vec![
+
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
+
            ],
+
            search: Some("cli".to_string()),
+
        };
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+
}