Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin/inbox: Improve item loading and filtering
Erik Kundt committed 4 months ago
commit e6ddafbc5fc3807db22d0bf44f0d70c2f9345633
parent d962ec4
7 files changed +1359 -816
modified bin/cob.rs
@@ -1,3 +1,2 @@
-
pub mod inbox;
pub mod issue;
pub mod patch;
deleted bin/cob/inbox.rs
@@ -1,38 +0,0 @@
-
use anyhow::Result;
-

-
use radicle::node::notifications::Notification;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct Filter {}
-

-
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-
pub struct SortBy {
-
    pub reverse: bool,
-
    pub field: &'static str,
-
}
-

-
impl Default for SortBy {
-
    fn default() -> Self {
-
        Self {
-
            reverse: true,
-
            field: "timestamp",
-
        }
-
    }
-
}
-

-
pub fn all(repository: &Repository, profile: &Profile) -> Result<Vec<Notification>> {
-
    let all = profile
-
        .notifications_mut()?
-
        .by_repo(&repository.id, "timestamp")?
-
        .collect::<Vec<_>>();
-

-
    let mut notifications = vec![];
-
    for n in all {
-
        let n = n?;
-
        notifications.push(n);
-
    }
-

-
    Ok(notifications)
-
}
modified bin/commands/inbox.rs
@@ -7,12 +7,14 @@ use std::ffi::OsString;

use anyhow::anyhow;

+
use radicle::storage::{HasRepoId, ReadRepository};
+

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

use self::common::{Mode, RepositoryMode, SelectionMode};

-
use crate::cob::inbox;
+
use crate::ui::items::notification::filter::{NotificationFilter, SortBy};

pub const HELP: Help = Help {
    name: "inbox",
@@ -59,11 +61,11 @@ pub enum OperationName {
    Unknown,
}

-
#[derive(Debug, Default, Clone, PartialEq, Eq)]
+
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ListOptions {
    mode: Mode,
-
    filter: inbox::Filter,
-
    sort_by: inbox::SortBy,
+
    filter: NotificationFilter,
+
    sort_by: SortBy,
    json: bool,
}

@@ -166,12 +168,12 @@ impl Args for Options {
            .mode
            .with_repository(repository_mode.unwrap_or_default());
        list_opts.sort_by = if let Some(field) = field {
-
            inbox::SortBy {
+
            SortBy {
                field,
                reverse: reverse.unwrap_or(false),
            }
        } else {
-
            inbox::SortBy::default()
+
            SortBy::default()
        };

        // Map local commands. Forward help and ignore `no-forward`.
@@ -206,12 +208,13 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul

            let context = list::Context {
                profile,
-
                repository,
+
                project: repository.identity_doc()?.project()?,
+
                rid: repository.rid(),
                mode: opts.mode,
                filter: opts.filter.clone(),
                sort_by: opts.sort_by,
            };
-
            let selection = list::App::new(context).run().await?;
+
            let selection = list::App::default().run(context).await?;

            if opts.json {
                let selection = selection
modified bin/commands/inbox/list.rs
@@ -2,167 +2,138 @@
mod ui;

use std::str::FromStr;
-

-
use anyhow::Result;
+
use std::sync::Arc;
+
use std::sync::Mutex;

use ratatui::Viewport;
-
use termion::event::Key;
-

-
use ratatui::layout::Constraint;
-
use ratatui::style::Stylize;
-
use ratatui::text::Text;

use radicle::identity::Project;
use radicle::node::notifications::NotificationId;
-
use radicle::storage::git::Repository;
-
use radicle::storage::ReadRepository;
+
use radicle::prelude::RepoId;
use radicle::storage::ReadStorage;
use radicle::Profile;

use radicle_tui as tui;

use tui::store;
-
use tui::ui::rm::widget::container::{Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::rm::widget::window::{
-
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
-
};
-
use tui::ui::rm::widget::{ToWidget, Widget};
-
use tui::ui::span;
+
use tui::task::{Process, Task};
+
use tui::ui::rm::widget::text::TextViewState;
+
use tui::ui::rm::widget::window::{Window, WindowProps};
+
use tui::ui::rm::widget::ToWidget;
use tui::ui::BufferedValue;
-
use tui::ui::Column;
use tui::{BoxedAny, Channel, Exit, PageStack};

-
use crate::cob::inbox;
-
use crate::ui::items::{Filter, NotificationItem, NotificationItemFilter};
-

-
use self::ui::Browser;
-
use self::ui::BrowserProps;
+
use crate::ui::items::filter::Filter;
+
use crate::ui::items::notification::filter::NotificationFilter;
+
use crate::ui::items::notification::filter::SortBy;
+
use crate::ui::items::notification::Notification;

-
use super::common::SelectionMode;
use super::common::{Mode, RepositoryMode};

type Selection = tui::Selection<NotificationId>;

-
#[allow(dead_code)]
+
#[derive(Clone, Debug)]
pub struct Context {
    pub profile: Profile,
-
    pub repository: Repository,
+
    pub project: Project,
+
    pub rid: RepoId,
    pub mode: Mode,
-
    pub filter: inbox::Filter,
-
    pub sort_by: inbox::SortBy,
+
    pub filter: NotificationFilter,
+
    pub sort_by: SortBy,
}

-
pub struct App {
-
    context: Context,
+
#[derive(Default)]
+
pub struct App {}
+

+
impl App {
+
    pub async fn run(&self, context: Context) -> anyhow::Result<Option<Selection>> {
+
        let channel = Channel::default();
+
        let state = State::new(context.clone())?;
+
        let tx = channel.tx.clone();
+

+
        let window = Window::default()
+
            .page(PageState::Browse, ui::browser_page(&state, &channel))
+
            .page(PageState::Help, ui::help_page(&state, &channel))
+
            .to_widget(tx.clone())
+
            .on_init(|| Some(Message::Reload))
+
            .on_update(|state: &State| {
+
                WindowProps::default()
+
                    .current_page(state.pages.peek().unwrap_or(&PageState::Browse).clone())
+
                    .to_boxed_any()
+
                    .into()
+
            });
+

+
        tui::rm(
+
            state,
+
            window,
+
            Viewport::Inline(20),
+
            channel,
+
            vec![Loader::new(context)],
+
        )
+
        .await
+
    }
}

-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum AppPage {
-
    Browse,
-
    Help,
+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Exit { selection: Option<Selection> },
+
    Select { selected: Option<usize> },
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
+
    OpenHelp,
+
    LeavePage,
+
    ScrollHelp { state: TextViewState },
+
    Reload,
+
    NotificationsLoaded(Vec<Notification>),
}

#[derive(Clone, Debug)]
pub struct BrowserState {
-
    items: Vec<NotificationItem>,
+
    items: Arc<Mutex<Vec<Notification>>>,
    selected: Option<usize>,
-
    filter: NotificationItemFilter,
+
    filter: NotificationFilter,
    search: BufferedValue<String>,
    show_search: bool,
+
    is_loading: bool,
}

impl BrowserState {
-
    pub fn notifications(&self) -> Vec<NotificationItem> {
-
        self.items
+
    fn items(&self) -> Vec<Notification> {
+
        let items = self.items.lock().unwrap();
+
        items
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
+
            .filter(|n| self.filter.matches(n))
            .cloned()
            .collect()
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct HelpState {
-
    text: TextViewState,
-
}

-
#[derive(Clone, Debug)]
-
pub struct State {
-
    mode: Mode,
-
    project: Project,
-
    pages: PageStack<AppPage>,
-
    browser: BrowserState,
-
    help: HelpState,
-
}
-

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

-
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
-
        let doc = context.repository.identity_doc()?;
-
        let project = doc.project()?;
-

-
        let search = BufferedValue::new(String::new());
-
        let filter = NotificationItemFilter::from_str(&search.read()).unwrap_or_default();
-

-
        let mut notifications = match &context.mode.repository() {
-
            RepositoryMode::All => {
-
                let mut repos = context.profile.storage.repositories()?;
-
                repos.sort_by_key(|r| r.rid);
-

-
                let mut notifs = vec![];
-
                for repo in repos {
-
                    let repo = context.profile.storage.repository(repo.rid)?;
-

-
                    let items = inbox::all(&repo, &context.profile)?
-
                        .iter()
-
                        .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
-
                        .filter_map(|item| item.ok())
-
                        .flatten()
-
                        .collect::<Vec<_>>();
-

-
                    notifs.extend(items);
-
                }
-

-
                notifs
-
            }
-
            RepositoryMode::Contextual => {
-
                let notifs = inbox::all(&context.repository, &context.profile)?;
+
    fn apply_filter(&mut self, filter: NotificationFilter) {
+
        self.filter = filter;
+
    }

-
                notifs
-
                    .iter()
-
                    .map(|notif| {
-
                        NotificationItem::new(&context.profile, &context.repository, notif)
-
                    })
-
                    .filter_map(|item| item.ok())
-
                    .flatten()
-
                    .collect::<Vec<_>>()
-
            }
-
            RepositoryMode::ByRepo((rid, _)) => {
-
                let repo = context.profile.storage.repository(*rid)?;
-
                let notifs = inbox::all(&repo, &context.profile)?;
+
    fn apply_notifications(&mut self, notifications: Vec<Notification>) {
+
        let mut items = self.items.lock().unwrap();
+
        *items = notifications;
+
    }

-
                notifs
-
                    .iter()
-
                    .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
-
                    .filter_map(|item| item.ok())
-
                    .flatten()
-
                    .collect::<Vec<_>>()
-
            }
-
        };
+
    fn apply_sorting(&mut self, context: &Context) {
+
        let mut items = self.items.lock().unwrap();
+
        // Apply sorting
+
        match context.sort_by.field {
+
            "timestamp" => items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
+
            "id" => items.sort_by(|a, b| a.id.cmp(&b.id)),
+
            _ => {}
+
        }
+
        if context.sort_by.reverse {
+
            items.reverse();
+
        }

        // Set project name
-
        let mode = match &context.mode.repository() {
+
        let mode = match context.mode.repository() {
            RepositoryMode::ByRepo((rid, _)) => {
-
                let project = context
-
                    .profile
-
                    .storage
-
                    .repository(*rid)?
-
                    .identity_doc()?
-
                    .project()?;
-
                let name = project.name().to_string();
-

+
                let name = context.project.name().to_string();
                context
                    .mode
                    .clone()
@@ -171,52 +142,54 @@ impl TryFrom<&Context> for State {
            _ => context.mode.clone(),
        };

-
        // Apply sorting
-
        match context.sort_by.field {
-
            "timestamp" => notifications.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
-
            "id" => notifications.sort_by(|a, b| a.id.cmp(&b.id)),
-
            _ => {}
-
        }
-
        if context.sort_by.reverse {
-
            notifications.reverse();
-
        }
-

        // Sort by project if all notifications are shown
        if let RepositoryMode::All = mode.repository() {
-
            notifications.sort_by(|a, b| a.project.cmp(&b.project));
+
            items.sort_by(|a, b| a.project.cmp(&b.project));
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct HelpState {
+
    text: TextViewState,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+
pub enum PageState {
+
    Browse,
+
    Help,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct State {
+
    pages: PageStack<PageState>,
+
    browser: BrowserState,
+
    help: HelpState,
+
    context: Arc<Mutex<Context>>,
+
}
+

+
impl State {
+
    fn new(context: Context) -> Result<Self, anyhow::Error> {
+
        let search = BufferedValue::new(context.filter.to_string());

        Ok(Self {
-
            mode: context.mode.clone(),
-
            project,
-
            pages: PageStack::new(vec![AppPage::Browse]),
+
            pages: PageStack::new(vec![PageState::Browse]),
            browser: BrowserState {
-
                items: notifications,
+
                items: Arc::new(Mutex::new(vec![])),
                selected: Some(0),
-
                filter,
+
                filter: context.filter.clone(),
                search,
                show_search: false,
+
                is_loading: false,
            },
            help: HelpState {
-
                text: TextViewState::default().content(help_text()),
+
                text: TextViewState::default().content(ui::help_text()),
            },
+
            context: Arc::new(Mutex::new(context)),
        })
    }
}

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Exit { selection: Option<Selection> },
-
    Select { selected: Option<usize> },
-
    OpenSearch,
-
    UpdateSearch { value: String },
-
    ApplySearch,
-
    CloseSearch,
-
    OpenHelp,
-
    LeavePage,
-
    ScrollHelp { state: TextViewState },
-
}
-

impl store::Update<Message> for State {
    type Return = Selection;

@@ -233,11 +206,14 @@ impl store::Update<Message> for State {
            }
            Message::UpdateSearch { value } => {
                self.browser.search.write(value);
-
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
-
                    .unwrap_or_default();
+
                self.browser.apply_filter(
+
                    NotificationFilter::from_str(&self.browser.search.read())
+
                        .unwrap_or(NotificationFilter::Invalid),
+
                );

+
                let items = self.browser.items.lock().unwrap();
                if let Some(selected) = self.browser.selected {
-
                    if selected > self.browser.notifications().len() {
+
                    if selected > items.len() {
                        self.browser.selected = Some(0);
                    }
                }
@@ -252,13 +228,15 @@ impl store::Update<Message> for State {
            Message::CloseSearch => {
                self.browser.search.reset();
                self.browser.show_search = false;
-
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
-
                    .unwrap_or_default();
+
                self.browser.apply_filter(
+
                    NotificationFilter::from_str(&self.browser.search.read())
+
                        .unwrap_or(NotificationFilter::Invalid),
+
                );

                None
            }
            Message::OpenHelp => {
-
                self.pages.push(AppPage::Help);
+
                self.pages.push(PageState::Help);
                None
            }
            Message::LeavePage => {
@@ -269,180 +247,122 @@ impl store::Update<Message> for State {
                self.help.text = state;
                None
            }
+
            Message::Reload => {
+
                self.browser.is_loading = true;
+
                None
+
            }
+
            Message::NotificationsLoaded(notifications) => {
+
                let context = self.context.lock().unwrap();
+
                self.browser.apply_notifications(notifications);
+
                self.browser.apply_filter(self.browser.filter.clone());
+
                self.browser.apply_sorting(&context);
+
                self.browser.is_loading = false;
+
                None
+
            }
        }
    }
}

-
impl App {
-
    pub fn new(context: Context) -> Self {
-
        Self { context }
-
    }
-

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let channel = Channel::default();
-
        let state = State::try_from(&self.context)?;
-
        let tx = channel.tx.clone();
-

-
        let window = Window::default()
-
            .page(AppPage::Browse, browser_page(&state, &channel))
-
            .page(AppPage::Help, help_page(&state, &channel))
-
            .to_widget(tx.clone())
-
            .on_update(|state: &State| {
-
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
-
                    .to_boxed_any()
-
                    .into()
-
            });
+
#[derive(Clone, Debug)]
+
pub struct Loader {
+
    context: Context,
+
}

-
        tui::rm(state, window, Viewport::Inline(20), channel).await
+
impl Loader {
+
    fn new(context: Context) -> Self {
+
        Self { context }
    }
}

-
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Browser::new(tx.clone())
-
        .to_widget(tx.clone())
-
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
-

-
    let shortcuts = Shortcuts::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state: &State| {
-
            let shortcuts = if state.browser.show_search {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                match state.mode.selection() {
-
                    SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
-
                    SelectionMode::Operation => vec![
-
                        ("enter", "show"),
-
                        ("c", "clear"),
-
                        ("/", "search"),
-
                        ("?", "help"),
-
                    ],
-
                }
-
            };
-

-
            ShortcutsProps::default()
-
                .shortcuts(&shortcuts)
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, props| {
-
            let default = PageProps::default();
-
            let props = props
-
                .and_then(|props| props.inner_ref::<PageProps>())
-
                .unwrap_or(&default);
-

-
            if props.handle_keys {
-
                match key {
-
                    Key::Char('q') | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
-
                    Key::Char('?') => Some(Message::OpenHelp),
-
                    _ => None,
-
                }
-
            } else {
-
                None
-
            }
-
        })
-
        .on_update(|state: &State| {
-
            PageProps::default()
-
                .handle_keys(!state.browser.show_search)
-
                .to_boxed_any()
-
                .into()
-
        })
+
#[derive(Debug)]
+
pub struct NotificationLoader {
+
    context: Context,
}

-
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Container::default()
-
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
-
            HeaderProps::default()
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .to_boxed_any()
-
                .into()
-
        }))
-
        .content(
-
            TextView::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, vs, _| {
-
                    vs.and_then(|vs| vs.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp { state: tvs })
-
                })
-
                .on_update(|state: &State| {
-
                    TextViewProps::default()
-
                        .state(Some(state.help.text.clone()))
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .footer(
-
            Footer::default()
-
                .to_widget(tx.clone())
-
                .on_update(|state: &State| {
-
                    FooterProps::default()
-
                        .columns(
-
                            [
-
                                Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
-
                                    Constraint::Min(4),
-
                                ),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone());
-

-
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
-
        ShortcutsProps::default()
-
            .shortcuts(&[("?", "close")])
-
            .to_boxed_any()
-
            .into()
-
    });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, _| match key {
-
            Key::Char('q') | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
-
            Key::Char('?') => Some(Message::LeavePage),
-
            _ => None,
-
        })
-
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
+
impl NotificationLoader {
+
    fn new(context: Context) -> Self {
+
        NotificationLoader { context }
+
    }
}

-
fn help_text() -> String {
-
    r#"# Generic keybindings
+
impl Task for NotificationLoader {
+
    type Return = Message;
+

+
    fn run(&self) -> anyhow::Result<Vec<Self::Return>> {
+
        let notifications = match self.context.mode.repository() {
+
            RepositoryMode::All => {
+
                let notifs = self.context.profile.notifications_mut()?;
+
                let all = notifs.all()?;

-
`↑,k`:      move cursor one line up
-
`↓,j:       move cursor one line down
-
`PageUp`:   move cursor one page up
-
`PageDown`: move cursor one page down
-
`Home`:     move cursor to the first line
-
`End`:      move cursor to the last line
-
`Esc`:      Cancel
-
`q`:        Quit
+
                all.filter_map(|notif| notif.ok())
+
                    .map(|notif| {
+
                        let repo = self.context.profile.storage.repository(notif.repo)?;
+
                        Notification::new(
+
                            &self.context.profile,
+
                            &self.context.project,
+
                            &repo,
+
                            &notif,
+
                        )
+
                    })
+
                    .filter_map(|notif| notif.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
            RepositoryMode::Contextual => {
+
                let repo = self.context.profile.storage.repository(self.context.rid)?;
+
                let notifs = self.context.profile.notifications_mut()?;
+
                let by_repo = notifs.by_repo(&repo.id, "timestamp")?;

-
# Specific keybindings
+
                by_repo
+
                    .filter_map(|notif| notif.ok())
+
                    .map(|notif| {
+
                        let repo = self.context.profile.storage.repository(notif.repo)?;
+
                        Notification::new(
+
                            &self.context.profile,
+
                            &self.context.project,
+
                            &repo,
+
                            &notif,
+
                        )
+
                    })
+
                    .filter_map(|notif| notif.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
            RepositoryMode::ByRepo((rid, _)) => {
+
                let repo = self.context.profile.storage.repository(*rid)?;
+
                let notifs = self.context.profile.notifications_mut()?;
+
                let by_repo = notifs.by_repo(&repo.id, "timestamp")?;

-
`enter`:    Select notification (if --mode id)
-
`enter`:    Show notification
-
`c`:        Clear notifications
-
`/`:        Search
-
`?`:        Show help
+
                by_repo
+
                    .filter_map(|notif| notif.ok())
+
                    .map(|notif| {
+
                        let repo = self.context.profile.storage.repository(notif.repo)?;
+
                        Notification::new(
+
                            &self.context.profile,
+
                            &self.context.project,
+
                            &repo,
+
                            &notif,
+
                        )
+
                    })
+
                    .filter_map(|notif| notif.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
        };

-
# Searching
+
        Ok(vec![Message::NotificationsLoaded(notifications)])
+
    }
+
}

-
Pattern:    is:<state> | is:patch | is:issue | <search>
-
Example:    is:unseen is:patch Print"#
-
        .into()
+
impl Process<Message> for Loader {
+
    async fn process(&mut self, message: Message) -> anyhow::Result<Vec<Message>> {
+
        match message {
+
            Message::Reload => {
+
                let loader = NotificationLoader::new(self.context.clone());
+
                let messages = tokio::spawn(async move { loader.run() }).await.unwrap()?;
+
                Ok(messages)
+
            }
+
            _ => Ok(vec![]),
+
        }
+
    }
}
modified bin/commands/inbox/list/ui.rs
@@ -1,35 +1,40 @@
use std::collections::HashMap;
use std::str::FromStr;

-
use ratatui::Frame;
-
use tokio::sync::mpsc::UnboundedSender;
+
use tokio::sync::broadcast;

use termion::event::Key;

use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
use ratatui::text::{Line, Text};
+
use ratatui::widgets::Clear;
+
use ratatui::Frame;

use radicle_tui as tui;

+
use tui::ui::rm::widget;
use tui::ui::rm::widget::container::{
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
-
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
use tui::ui::rm::widget::list::{Table, TableProps};
-
use tui::ui::rm::widget::{self, ViewProps};
-
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::rm::widget::text::{
+
    Label, LabelProps, TextField, TextFieldProps, TextView, TextViewProps,
+
};
+
use tui::ui::rm::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps};
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View, ViewProps};
use tui::ui::span;
+
use tui::ui::theme;
use tui::ui::Column;
-

-
use tui::{BoxedAny, Selection};
+
use tui::{BoxedAny, Channel, Selection};

use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
-
use crate::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
+
use crate::ui::items::notification::filter::NotificationFilter;
+
use crate::ui::items::notification::{Notification, NotificationState};

use super::{Message, State};

-
type Widget = widget::Widget<State, Message>;
+
type AppWidget = widget::Widget<State, Message>;

#[derive(Clone, Default)]
pub struct BrowserProps<'a> {
@@ -38,7 +43,7 @@ pub struct BrowserProps<'a> {
    /// Table title
    header: String,
    /// Filtered notifications.
-
    notifications: Vec<NotificationItem>,
+
    notifications: Vec<Notification>,
    /// Current (selected) table index
    selected: Option<usize>,
    /// Notification statistics.
@@ -49,17 +54,21 @@ pub struct BrowserProps<'a> {
    show_search: bool,
    /// Current search string.
    search: String,
+
    /// If loading widget should be shown
+
    show_loading: bool,
}

impl From<&State> for BrowserProps<'_> {
    fn from(state: &State) -> Self {
-
        let header = match state.mode.repository() {
-
            RepositoryMode::Contextual => state.project.name().to_string(),
+
        let context = state.context.lock().unwrap();
+

+
        let header = match context.mode.repository() {
+
            RepositoryMode::Contextual => context.project.name().to_string(),
            RepositoryMode::All => "All repositories".to_string(),
            RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(),
        };

-
        let notifications = state.browser.notifications();
+
        let notifications = state.browser.items.lock().unwrap().to_vec();

        // Compute statistics
        let mut seen = 0;
@@ -74,9 +83,9 @@ impl From<&State> for BrowserProps<'_> {
        let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]);

        Self {
-
            mode: state.mode.clone(),
+
            mode: context.mode.clone(),
            header,
-
            notifications,
+
            notifications: notifications,
            selected: state.browser.selected,
            stats,
            columns: [
@@ -84,11 +93,11 @@ impl From<&State> for BrowserProps<'_> {
                Column::new("", Constraint::Length(3)),
                Column::new("", Constraint::Fill(5)),
                Column::new("", Constraint::Fill(1))
-
                    .skip(*state.mode.repository() != RepositoryMode::All),
+
                    .skip(*context.mode.repository() != RepositoryMode::All),
                Column::new("", Constraint::Fill(1))
                    .hide_small()
                    .hide_medium(),
-
                Column::new("", Constraint::Length(8)),
+
                Column::new("", Constraint::Length(20)),
                Column::new("", Constraint::Length(10)),
                Column::new("", Constraint::Min(12)).hide_small(),
                Column::new("", Constraint::Min(14)).hide_small(),
@@ -96,19 +105,22 @@ impl From<&State> for BrowserProps<'_> {
            .to_vec(),
            search: state.browser.search.read(),
            show_search: state.browser.show_search,
+
            show_loading: state.browser.is_loading,
        }
    }
}

pub struct Browser {
    /// Notification widget
-
    notifications: Widget,
+
    notifications: AppWidget,
+
    /// Loader widget
+
    loader: AppWidget,
    /// Search widget
-
    search: Widget,
+
    search: AppWidget,
}

impl Browser {
-
    pub fn new(tx: UnboundedSender<Message>) -> Self {
+
    pub fn new(tx: broadcast::Sender<Message>) -> Self {
        Self {
            notifications: Container::default()
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
@@ -126,7 +138,7 @@ impl Browser {
                        .into()
                }))
                .content(
-
                    Table::<State, Message, NotificationItem, 9>::default()
+
                    Table::<State, Message, Notification, 9>::default()
                        .to_widget(tx.clone())
                        .on_event(|_, s, _| {
                            let (selected, _) =
@@ -137,10 +149,11 @@ impl Browser {
                        })
                        .on_update(|state| {
                            let props = BrowserProps::from(state);
+
                            let items = state.browser.items();

                            TableProps::default()
                                .columns(props.columns)
-
                                .items(state.browser.notifications())
+
                                .items(items)
                                .selected(state.browser.selected)
                                .to_boxed_any()
                                .into()
@@ -161,6 +174,15 @@ impl Browser {
                        .to_boxed_any()
                        .into()
                }),
+
            loader: Container::default()
+
                .content(Label::default().to_widget(tx.clone()).on_update(|_| {
+
                    LabelProps::default()
+
                        .text(" Loading ")
+
                        .style(theme::style::yellow().slow_blink())
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .to_widget(tx.clone()),
            search: TextField::default()
                .to_widget(tx.clone())
                .on_event(|_, s, _| {
@@ -240,6 +262,7 @@ impl View for Browser {

    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
        self.notifications.update(state);
+
        self.loader.update(state);
        self.search.update(state);
    }

@@ -249,6 +272,17 @@ impl View for Browser {
            .and_then(|props| props.inner_ref::<BrowserProps>())
            .unwrap_or(&default);

+
        let loader_area = |table_area| {
+
            let [table_area, _] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).areas(table_area);
+
            let [_, loader_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).areas(table_area);
+
            let [_, loader_area] =
+
                Layout::horizontal([Constraint::Length(1), Constraint::Length(11)])
+
                    .areas(loader_area);
+
            loader_area
+
        };
+

        if props.show_search {
            let [table_area, search_area] =
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
@@ -258,17 +292,183 @@ impl View for Browser {
                Constraint::Length(1),
            ])
            .areas(search_area);
+
            let loader_area = loader_area(table_area);

            self.notifications
                .render(RenderProps::from(table_area), frame);
+
            if props.show_loading {
+
                frame.render_widget(Clear, loader_area);
+
                self.loader.render(RenderProps::from(loader_area), frame);
+
            }
+

            self.search
                .render(RenderProps::from(search_area).focus(render.focus), frame);
        } else {
-
            self.notifications.render(render, frame);
+
            let loader_area = loader_area(render.area);
+

+
            self.notifications.render(render.clone(), frame);
+
            if props.show_loading {
+
                frame.render_widget(Clear, loader_area);
+
                self.loader.render(RenderProps::from(loader_area), frame);
+
            }
        }
    }
}

+
pub fn browser_page(_state: &State, channel: &Channel<Message>) -> AppWidget {
+
    let tx = channel.tx.clone();
+

+
    let content = Browser::new(tx.clone())
+
        .to_widget(tx.clone())
+
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
+

+
    let shortcuts = Shortcuts::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            let context = state.context.lock().unwrap();
+
            let shortcuts = if state.browser.show_search {
+
                vec![("esc", "cancel"), ("enter", "apply")]
+
            } else {
+
                match context.mode.selection() {
+
                    SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
+
                    SelectionMode::Operation => vec![
+
                        ("enter", "show"),
+
                        ("c", "clear"),
+
                        ("r", "reload"),
+
                        ("/", "search"),
+
                        ("?", "help"),
+
                    ],
+
                }
+
            };
+

+
            ShortcutsProps::default()
+
                .shortcuts(&shortcuts)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, props| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+

+
            if props.handle_keys {
+
                match key {
+
                    Key::Char('r') => Some(Message::Reload),
+
                    Key::Char('q') | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    _ => None,
+
                }
+
            } else {
+
                None
+
            }
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .handle_keys(!state.browser.show_search)
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
pub fn help_page(_state: &State, channel: &Channel<Message>) -> AppWidget {
+
    let tx = channel.tx.clone();
+

+
    let content = Container::default()
+
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
+
            HeaderProps::default()
+
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
+
                .to_boxed_any()
+
                .into()
+
        }))
+
        .content(
+
            TextView::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, vs, _| {
+
                    vs.and_then(|vs| vs.unwrap_textview())
+
                        .map(|tvs| Message::ScrollHelp { state: tvs })
+
                })
+
                .on_update(|state: &State| {
+
                    TextViewProps::default()
+
                        .state(Some(state.help.text.clone()))
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .footer(
+
            Footer::default()
+
                .to_widget(tx.clone())
+
                .on_update(|state: &State| {
+
                    FooterProps::default()
+
                        .columns(
+
                            [
+
                                Column::new(Text::raw(""), Constraint::Fill(1)),
+
                                Column::new(
+
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
+
                                    Constraint::Min(4),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone());
+

+
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
+
        ShortcutsProps::default()
+
            .shortcuts(&[("?", "close")])
+
            .to_boxed_any()
+
            .into()
+
    });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Char('q') | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
            Key::Char('?') => Some(Message::LeavePage),
+
            _ => None,
+
        })
+
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
+
}
+

+
pub fn help_text() -> String {
+
    r#"# Generic keybindings
+

+
`↑,k`:      move cursor one line up
+
`↓,j:       move cursor one line down
+
`PageUp`:   move cursor one page up
+
`PageDown`: move cursor one page down
+
`Home`:     move cursor to the first line
+
`End`:      move cursor to the last line
+
`Esc`:      Cancel
+
`q`:        Quit
+

+
# Specific keybindings
+

+
`enter`:    Select notification (if --mode id)
+
`enter`:    Show notification
+
`r`:        Reload notifications
+
`c`:        Clear notification
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Examples:   state=unseen type=patch bugfix
+
            type=(issue or patch)
+
            state=unseen author=(did:key:... or did:key:...)"#
+
        .into()
+
}
+

fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
    let search = Line::from(vec![
        span::default(" Search ").cyan().dim().reversed(),
@@ -291,10 +491,21 @@ fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
        span::default(&props.notifications.len().to_string()).dim(),
    ]);

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

+
    match state {
        Some(state) => {
            let block = match state {
                NotificationState::Seen => seen,
modified bin/ui/items.rs
@@ -1,3 +1,5 @@
+
pub mod notification;
+

use std::collections::HashMap;
use std::fmt;
use std::fmt::Debug;
@@ -12,17 +14,16 @@ use nom::{IResult, Parser};
use ansi_to_tui::IntoText;

use radicle::cob::thread::{Comment, CommentId};
-
use radicle::cob::{CodeLocation, CodeRange, EntryId, Label, ObjectId, Timestamp, Title, TypedId};
+
use radicle::cob::{CodeLocation, CodeRange, EntryId, Label, Timestamp};
use radicle::git::Oid;
-
use radicle::identity::{Did, Identity};
+
use radicle::identity::Did;
use radicle::issue;
-
use radicle::issue::{CloseReason, Issue, IssueId, Issues};
-
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
+
use radicle::issue::{CloseReason, Issue, IssueId};
use radicle::node::{Alias, AliasStore, NodeId};
use radicle::patch;
-
use radicle::patch::{Patch, PatchId, Patches, Review};
+
use radicle::patch::{Patch, PatchId, Review};
use radicle::storage::git::Repository;
-
use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, WriteRepository};
+
use radicle::storage::WriteRepository;
use radicle::Profile;

use radicle_surf::diff;
@@ -40,7 +41,6 @@ use tui_tree_widget::TreeItem;

use radicle_tui as tui;

-
use tui::ui::theme::style;
use tui::ui::utils::{LineMerger, MergeLocation};
use tui::ui::{span, Column};
use tui::ui::{ToRow, ToTree};
@@ -51,434 +51,109 @@ use crate::ui;
use super::super::git;
use super::format;

-
pub trait Filter<T> {
-
    fn matches(&self, item: &T) -> bool;
-
}
+
pub mod filter {
+
    use std::fmt::{self, Write};
+
    use std::str::FromStr;

-
#[derive(Clone, Debug, PartialEq, Eq)]
-
pub struct AuthorItem {
-
    pub nid: Option<NodeId>,
-
    pub human_nid: Option<String>,
-
    pub alias: Option<Alias>,
-
    pub you: bool,
-
}
+
    use nom::bytes::complete::{tag_no_case, take};
+
    use nom::character::complete::{char, multispace0};
+
    use nom::combinator::map;
+
    use nom::multi::separated_list1;
+
    use nom::sequence::{delimited, tuple};
+
    use nom::IResult;

-
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();
-
        let human_nid = nid.map(|nid| format::did(&Did::from(nid)));
+
    use radicle::prelude::Did;

-
        Self {
-
            nid,
-
            human_nid,
-
            alias,
-
            you,
-
        }
+
    /// A generic filter that needs be implemented for item filters in order to
+
    /// apply it.
+
    pub trait Filter<T> {
+
        fn matches(&self, item: &T) -> bool;
    }
-
}

-
#[derive(Clone, Debug)]
-
#[allow(dead_code)]
-
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,
-
    },
-
}
+
    #[derive(Debug, Clone, PartialEq, Eq)]
+
    pub enum DidFilter {
+
        Single(Did),
+
        Or(Vec<Did>),
+
    }

-
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()),
+
    impl fmt::Display for DidFilter {
+
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
            match self {
+
                DidFilter::Single(did) => write!(f, "{did}")?,
+
                DidFilter::Or(dids) => {
+
                    let mut it = dids.iter().peekable();
+
                    f.write_char('(')?;
+
                    while let Some(did) = it.next() {
+
                        write!(f, "{did}")?;
+
                        if it.peek().is_none() {
+
                            write!(f, " or ")?;
+
                        }
+
                    }
+
                    f.write_char(')')?;
                }
-
                .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);
-
                    };
-
                    (
-
                        "issue".to_string(),
-
                        Title::new(issue.title())?,
-
                        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);
-
                    };
-
                    (
-
                        "patch".to_string(),
-
                        Title::new(patch.title())?,
-
                        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(),
-
                        Title::new("")?,
-
                        "".to_string(),
-
                    )
-
                };
-

-
                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(),
-
            })),
+
            Ok(())
        }
    }
-
}
-

-
#[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);
+
    fn parse_did(input: &str) -> IResult<&str, Did> {
+
        match Did::from_str(input) {
+
            Ok(did) => IResult::Ok(("", did)),
+
            Err(_) => IResult::Err(nom::Err::Error(nom::error::Error::new(
+
                input,
+
                nom::error::ErrorKind::Verify,
+
            ))),
        }
+
    }

-
        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(),
-
        }))
+
    pub fn parse_did_single(input: &str) -> IResult<&str, DidFilter> {
+
        map(parse_did, DidFilter::Single)(input)
    }
-
}

-
impl ToRow<9> for NotificationItem {
-
    fn to_row(&self) -> [Cell; 9] {
-
        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(),
+
    pub fn parse_did_or(input: &str) -> IResult<&str, DidFilter> {
+
        map(
+
            delimited(
+
                tuple((multispace0, char('('), multispace0)),
+
                separated_list1(
+
                    delimited(multispace0, tag_no_case("or"), multispace0),
+
                    take(56_usize),
+
                ),
+
                tuple((multispace0, char(')'), multispace0)),
            ),
-
            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(),
+
            |dids: Vec<&str>| {
+
                DidFilter::Or(
+
                    dids.iter()
+
                        .filter_map(|did| Did::from_str(did).ok())
+
                        .collect::<Vec<_>>(),
                )
-
            }
-
            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(" ● ")
-
        };
-
        let kind_id = span::primary(&kind_id);
-
        let summary = span::default(&summary);
-
        let type_name = span::notification_type(&type_name);
-
        let name = span::default(&self.project.clone()).style(style::gray().dim());
-

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

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

-
#[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()
+
        )(input)
    }
}

-
impl Filter<NotificationItem> for NotificationItemFilter {
-
    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 = if !self.authors.is_empty() {
-
            {
-
                self.authors
-
                    .iter()
-
                    .any(|other| notif.author.nid == Some(**other))
-
            }
-
        } else {
-
            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
-
    }
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct AuthorItem {
+
    pub nid: Option<NodeId>,
+
    pub human_nid: Option<String>,
+
    pub alias: Option<Alias>,
+
    pub you: bool,
}

-
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)
+
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();
+
        let human_nid = nid.map(|nid| format::did(&Did::from(nid)));

-
        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);
-
                    }
-
                }
-
            }
+
        Self {
+
            nid,
+
            human_nid,
+
            alias,
+
            you,
        }
-

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

@@ -598,7 +273,7 @@ impl IssueItemFilter {
    }
}

-
impl Filter<IssueItem> for IssueItemFilter {
+
impl filter::Filter<IssueItem> for IssueItemFilter {
    fn matches(&self, issue: &IssueItem) -> bool {
        use fuzzy_matcher::skim::SkimMatcherV2;
        use fuzzy_matcher::FuzzyMatcher;
@@ -850,7 +525,7 @@ impl PatchItemFilter {
    }
}

-
impl Filter<PatchItem> for PatchItemFilter {
+
impl filter::Filter<PatchItem> for PatchItemFilter {
    fn matches(&self, patch: &PatchItem) -> bool {
        use fuzzy_matcher::skim::SkimMatcherV2;
        use fuzzy_matcher::FuzzyMatcher;
@@ -1758,7 +1433,7 @@ impl Debug for HunkItem<'_> {
    }
}

-
#[derive(Clone)]
+
#[derive(Clone, Debug)]
pub struct StatefulHunkItem<'a>(HunkItem<'a>, HunkState);

impl<'a> StatefulHunkItem<'a> {
@@ -2028,26 +1703,6 @@ mod tests {
    }

    #[test]
-
    fn notification_item_filter_from_str_should_succeed() -> Result<()> {
-
        let search = r#"is:seen is:patch authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
-
        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(())
-
    }
-

-
    #[test]
    fn diff_line_index_checks_ranges_correctly() -> Result<()> {
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();
added bin/ui/items/notification.rs
@@ -0,0 +1,793 @@
+
use std::fmt;
+

+
use radicle::cob::{ObjectId, Timestamp, TypeName, TypedId};
+
use radicle::identity::Identity;
+
use radicle::issue::Issues;
+
use radicle::node;
+
use radicle::patch::Patches;
+
use radicle::prelude::Project;
+
use radicle::storage::git::Repository;
+
use radicle::storage::{ReadRepository, RefUpdate};
+
use radicle::Profile;
+

+
use ratatui::style::Stylize;
+
use ratatui::widgets::Cell;
+

+
use radicle_tui as tui;
+

+
use tui::ui::span;
+
use tui::ui::theme::style;
+
use tui::ui::ToRow;
+

+
use super::AuthorItem;
+

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

+
impl fmt::Display for NotificationState {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            NotificationState::Seen => write!(f, "seen")?,
+
            NotificationState::Unseen => write!(f, "unseen")?,
+
        }
+
        Ok(())
+
    }
+
}
+

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

+
impl PartialEq for NotificationKind {
+
    fn eq(&self, other: &Self) -> bool {
+
        match self {
+
            NotificationKind::Cob { type_name, .. } => {
+
                let other_type_name = match other {
+
                    NotificationKind::Cob { type_name, .. } => type_name.clone(),
+
                    _ => None,
+
                };
+
                other_type_name.is_some() && (other_type_name == *type_name)
+
            }
+
            NotificationKind::Branch { name, .. } => {
+
                let other_name = match other {
+
                    NotificationKind::Branch { name, .. } => name.clone(),
+
                    _ => None,
+
                };
+
                other_name.is_some() && (other_name == *name)
+
            }
+
            NotificationKind::Unknown { refname } => {
+
                let other_refname = match other {
+
                    NotificationKind::Unknown { refname, .. } => refname.clone(),
+
                    _ => None,
+
                };
+
                other_refname.is_some() && (other_refname == *refname)
+
            }
+
        }
+
    }
+
}
+

+
impl fmt::Display for NotificationKind {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            NotificationKind::Cob { type_name, .. } => {
+
                write!(f, "cob")?;
+
                if let Some(type_name) = type_name {
+
                    write!(f, ":{type_name}")?;
+
                }
+
            }
+
            NotificationKind::Branch { name, .. } => {
+
                write!(f, "branch")?;
+
                if let Some(name) = name {
+
                    write!(f, ":{name}")?;
+
                }
+
            }
+
            NotificationKind::Unknown { refname, .. } => {
+
                write!(f, "unknown")?;
+
                if let Some(refname) = refname {
+
                    write!(f, ":{refname}")?;
+
                }
+
            }
+
        }
+
        Ok(())
+
    }
+
}
+

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

+
        match &notification.kind {
+
            node::notifications::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(NotificationKind::Branch {
+
                    name: Some(name.to_string()),
+
                    summary: Some(message),
+
                    status: Some(status.to_string()),
+
                    _id: head.map(ObjectId::from),
+
                }))
+
            }
+
            node::notifications::NotificationKind::Cob { typed_id } => {
+
                let TypedId { id, type_name } = typed_id;
+
                let (type_name, 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);
+
                    };
+
                    (
+
                        type_name,
+
                        issue.title().to_string(),
+
                        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);
+
                    };
+
                    (
+
                        type_name,
+
                        patch.title().to_string(),
+
                        patch.state().to_string(),
+
                    )
+
                } else if typed_id.is_identity() {
+
                    let Ok(identity) = Identity::get(id, repo) else {
+
                        log::error!(
+
                            target: "items",
+
                            "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: "items",
+
                            "Error retrieving identity revision for notification {}", notification.id
+
                        );
+
                        return Ok(None);
+
                    };
+
                    (type_name, rev.title.to_string(), rev.state.to_string())
+
                } else {
+
                    (type_name, "".to_string(), "".to_string())
+
                };
+

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

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

+
impl Notification {
+
    pub fn new(
+
        profile: &Profile,
+
        project: &Project,
+
        repo: &Repository,
+
        notification: &node::notifications::Notification,
+
    ) -> Result<Option<Self>, anyhow::Error> {
+
        let name = project.name().to_string();
+
        let kind = NotificationKind::new(repo, notification)?;
+

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

+
        Ok(Some(Notification {
+
            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<9> for Notification {
+
    fn to_row(&self) -> [Cell; 9] {
+
        let (type_name, summary, status, kind_id) = match &self.kind {
+
            NotificationKind::Branch {
+
                name,
+
                summary,
+
                status,
+
                _id: _,
+
            } => (
+
                Some("branch".to_string()),
+
                summary.clone(),
+
                status.clone(),
+
                name.clone(),
+
            ),
+
            NotificationKind::Cob {
+
                type_name,
+
                summary,
+
                status,
+
                id,
+
            } => {
+
                let id = id.map(|id| super::format::cob(&id)).unwrap_or_default();
+
                (
+
                    type_name.as_ref().map(|t| t.to_string()),
+
                    summary.clone(),
+
                    status.clone(),
+
                    Some(id.to_string()),
+
                )
+
            }
+
            NotificationKind::Unknown { refname } => (refname.clone(), None, None, None),
+
        };
+

+
        let id = span::notification_id(&format!(" {:-03}", &self.id));
+
        let seen = if self.seen {
+
            span::blank()
+
        } else {
+
            span::primary(" ● ")
+
        };
+
        let kind_id = span::primary(&kind_id.unwrap_or_default());
+
        let summary = span::default(&summary.unwrap_or_default());
+
        let type_name = span::notification_type(&type_name.unwrap_or_default());
+
        let name = span::default(&self.project.clone()).style(style::gray().dim());
+

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

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

+
pub mod filter {
+
    use std::fmt;
+
    use std::fmt::Write as _;
+
    use std::str::FromStr;
+

+
    use nom::branch::alt;
+
    use nom::bytes::complete::{tag, tag_no_case, take_while1};
+
    use nom::character::complete::{char, multispace0};
+
    use nom::combinator::{map, opt, value};
+
    use nom::multi::{many0, separated_list1};
+
    use nom::sequence::{delimited, preceded, tuple};
+
    use nom::IResult;
+
    use radicle::cob::TypeName;
+

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

+
    use super::{Notification, NotificationKind, NotificationState};
+

+
    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
    pub struct SortBy {
+
        pub reverse: bool,
+
        pub field: &'static str,
+
    }
+

+
    impl Default for SortBy {
+
        fn default() -> Self {
+
            Self {
+
                reverse: true,
+
                field: "timestamp",
+
            }
+
        }
+
    }
+

+
    #[derive(Debug, Clone, PartialEq)]
+
    pub enum NotificationFilter {
+
        State(NotificationState),
+
        Kind(NotificationKindFilter),
+
        Author(DidFilter),
+
        Search(String),
+
        And(Vec<NotificationFilter>),
+
        Empty,
+
        Invalid,
+
    }
+

+
    impl Default for NotificationFilter {
+
        fn default() -> Self {
+
            NotificationFilter::And(vec![
+
                NotificationFilter::Kind(NotificationKindFilter::Or(vec![
+
                    NotificationKind::Cob {
+
                        type_name: TypeName::from_str("xyz.radicle.patch").ok(),
+
                        summary: None,
+
                        status: None,
+
                        id: None,
+
                    },
+
                    NotificationKind::Cob {
+
                        type_name: TypeName::from_str("xyz.radicle.issue").ok(),
+
                        summary: None,
+
                        status: None,
+
                        id: None,
+
                    },
+
                ])),
+
                NotificationFilter::State(NotificationState::Unseen),
+
            ])
+
        }
+
    }
+

+
    impl fmt::Display for NotificationFilter {
+
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
            match self {
+
                NotificationFilter::State(state) => {
+
                    write!(f, "state={state}")?;
+
                    f.write_char(' ')?;
+
                }
+
                NotificationFilter::Kind(filter) => {
+
                    write!(f, "kind={filter}")?;
+
                    f.write_char(' ')?;
+
                }
+
                NotificationFilter::Author(filter) => {
+
                    write!(f, "author={filter}")?;
+
                    f.write_char(' ')?;
+
                }
+
                NotificationFilter::Search(search) => {
+
                    write!(f, "{search}")?;
+
                    f.write_char(' ')?;
+
                }
+
                NotificationFilter::And(filters) => {
+
                    let mut it = filters.iter().peekable();
+
                    while let Some(filter) = it.next() {
+
                        write!(f, "{filter}")?;
+
                        if it.peek().is_none() {
+
                            f.write_char(' ')?;
+
                        }
+
                    }
+
                }
+
                NotificationFilter::Empty | NotificationFilter::Invalid => {}
+
            }
+

+
            Ok(())
+
        }
+
    }
+

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

+
    impl fmt::Display for NotificationKindFilter {
+
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
            match self {
+
                NotificationKindFilter::Single(type_name) => write!(f, "{type_name}")?,
+
                NotificationKindFilter::Or(type_names) => {
+
                    let mut it = type_names.iter().peekable();
+
                    f.write_char('(')?;
+
                    while let Some(type_name) = it.next() {
+
                        write!(f, "{type_name}")?;
+
                        if it.peek().is_some() {
+
                            write!(f, " or ")?;
+
                        }
+
                    }
+
                    f.write_char(')')?;
+
                }
+
            }
+
            Ok(())
+
        }
+
    }
+

+
    impl Filter<Notification> for NotificationFilter {
+
        fn matches(&self, notif: &Notification) -> bool {
+
            use fuzzy_matcher::skim::SkimMatcherV2;
+
            use fuzzy_matcher::FuzzyMatcher;
+

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

+
            match self {
+
                NotificationFilter::State(state) => match state {
+
                    NotificationState::Seen => notif.seen,
+
                    NotificationState::Unseen => !notif.seen,
+
                },
+
                NotificationFilter::Kind(type_filter) => match type_filter {
+
                    NotificationKindFilter::Single(kind) => &notif.kind == kind,
+
                    NotificationKindFilter::Or(kinds) => {
+
                        kinds.iter().any(|kind| &notif.kind == kind)
+
                    }
+
                },
+
                NotificationFilter::Author(author_filter) => match author_filter {
+
                    DidFilter::Single(author) => notif.author.nid == Some(**author),
+
                    DidFilter::Or(authors) => authors
+
                        .iter()
+
                        .any(|other| notif.author.nid == Some(**other)),
+
                },
+
                NotificationFilter::Search(search) => {
+
                    let summary = match &notif.kind {
+
                        NotificationKind::Cob {
+
                            type_name: _,
+
                            summary,
+
                            status: _,
+
                            id: _,
+
                        } => summary.clone().unwrap_or_default(),
+
                        NotificationKind::Branch {
+
                            name: _,
+
                            summary,
+
                            status: _,
+
                            _id: _,
+
                        } => summary.clone().unwrap_or_default(),
+
                        NotificationKind::Unknown { refname } => {
+
                            refname.clone().unwrap_or_default()
+
                        }
+
                    };
+
                    match matcher.fuzzy_match(&summary, search) {
+
                        Some(score) => score == 0 || score > 60,
+
                        _ => false,
+
                    }
+
                }
+
                NotificationFilter::And(filters) => filters.iter().all(|f| f.matches(notif)),
+
                NotificationFilter::Empty => true,
+
                NotificationFilter::Invalid => false,
+
            }
+
        }
+
    }
+

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

+
        fn from_str(filter_exp: &str) -> Result<Self, Self::Err> {
+
            fn parse_state(input: &str) -> IResult<&str, NotificationState> {
+
                alt((
+
                    value(NotificationState::Seen, tag_no_case("seen")),
+
                    value(NotificationState::Unseen, tag_no_case("unseen")),
+
                ))(input)
+
            }
+

+
            fn parse_name(input: &str) -> IResult<&str, &str> {
+
                take_while1(|c: char| {
+
                    c.is_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '/'
+
                })(input)
+
            }
+

+
            fn parse_type_name(input: &str) -> IResult<&str, TypeName> {
+
                let (input, type_name) =
+
                    take_while1(|c: char| c.is_alphanumeric() || c == '.')(input)?;
+

+
                match TypeName::from_str(type_name) {
+
                    Ok(t) => IResult::Ok((input, t)),
+
                    Err(_) => IResult::Err(nom::Err::Error(nom::error::Error::new(
+
                        input,
+
                        nom::error::ErrorKind::Verify,
+
                    ))),
+
                }
+
            }
+

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

+
            fn parse_cob_kind(input: &str) -> IResult<&str, NotificationKind> {
+
                let (input, _) = tag("cob")(input)?;
+
                let (input, type_name) = opt(preceded(tag(":"), parse_type_name))(input)?;
+

+
                Ok((
+
                    input,
+
                    NotificationKind::Cob {
+
                        type_name,
+
                        summary: None,
+
                        status: None,
+
                        id: None,
+
                    },
+
                ))
+
            }
+

+
            fn parse_branch_kind(input: &str) -> IResult<&str, NotificationKind> {
+
                let (input, _) = tag("branch")(input)?;
+
                let (input, name) = opt(preceded(tag(":"), parse_name))(input)?;
+

+
                Ok((
+
                    input,
+
                    NotificationKind::Branch {
+
                        name: name.map(|n| n.to_string()),
+
                        summary: None,
+
                        status: None,
+
                        _id: None,
+
                    },
+
                ))
+
            }
+

+
            fn parse_unknown_kind(input: &str) -> IResult<&str, NotificationKind> {
+
                let (input, _) = tag("unknown")(input)?;
+
                let (input, refname) = opt(preceded(tag(":"), parse_name))(input)?;
+

+
                Ok((
+
                    input,
+
                    NotificationKind::Unknown {
+
                        refname: refname.map(|r| r.to_string()),
+
                    },
+
                ))
+
            }
+

+
            fn parse_kind(input: &str) -> IResult<&str, NotificationKind> {
+
                alt((parse_cob_kind, parse_branch_kind, parse_unknown_kind))(input)
+
            }
+

+
            fn parse_kind_single(input: &str) -> IResult<&str, NotificationKindFilter> {
+
                map(parse_kind, NotificationKindFilter::Single)(input)
+
            }
+

+
            fn parse_kind_or(input: &str) -> IResult<&str, NotificationKindFilter> {
+
                map(
+
                    delimited(
+
                        tuple((multispace0, char('('), multispace0)),
+
                        separated_list1(
+
                            delimited(multispace0, tag_no_case("or"), multispace0),
+
                            parse_kind,
+
                        ),
+
                        tuple((multispace0, char(')'), multispace0)),
+
                    ),
+
                    NotificationKindFilter::Or,
+
                )(input)
+
            }
+

+
            fn parse_kind_filter(input: &str) -> IResult<&str, NotificationFilter> {
+
                map(
+
                    preceded(
+
                        tuple((
+
                            tag_no_case("kind"),
+
                            multispace0,
+
                            tag_no_case("="),
+
                            multispace0,
+
                        )),
+
                        alt((parse_kind_or, parse_kind_single)),
+
                    ),
+
                    NotificationFilter::Kind,
+
                )(input)
+
            }
+

+
            fn parse_author_filter(input: &str) -> IResult<&str, NotificationFilter> {
+
                map(
+
                    preceded(
+
                        tuple((
+
                            tag_no_case("author"),
+
                            multispace0,
+
                            tag_no_case("="),
+
                            multispace0,
+
                        )),
+
                        alt((filter::parse_did_single, filter::parse_did_or)),
+
                    ),
+
                    NotificationFilter::Author,
+
                )(input)
+
            }
+

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

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

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

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

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

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

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

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

+
    use anyhow::Result;
+
    use radicle::prelude::Did;
+

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

+
    use super::filter::*;
+
    use super::*;
+

+
    #[test]
+
    fn notification_item_filter_with_concrete_kind_should_succeed() -> Result<()> {
+
        let search = r#"kind=cob:xyz.radicle.patch"#;
+
        let actual = NotificationFilter::from_str(search)?;
+

+
        let expected =
+
            NotificationFilter::Kind(NotificationKindFilter::Single(NotificationKind::Cob {
+
                type_name: TypeName::from_str("xyz.radicle.patch").ok(),
+
                summary: None,
+
                status: None,
+
                id: None,
+
            }));
+

+
        assert_eq!(actual, expected);
+

+
        Ok(())
+
    }
+

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

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

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+

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

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

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

+
        Ok(())
+
    }
+

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

+
        let expected = NotificationFilter::And(vec![
+
            NotificationFilter::State(NotificationState::Seen),
+
            NotificationFilter::Kind(NotificationKindFilter::Or(vec![
+
                NotificationKind::Cob {
+
                    type_name: TypeName::from_str("xyz.radicle.patch").ok(),
+
                    summary: None,
+
                    status: None,
+
                    id: None,
+
                },
+
                NotificationKind::Cob {
+
                    type_name: TypeName::from_str("xyz.radicle.issue").ok(),
+
                    summary: None,
+
                    status: None,
+
                    id: None,
+
                },
+
            ])),
+
            NotificationFilter::Author(DidFilter::Or(vec![
+
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
+
            ])),
+
            NotificationFilter::Search("cli".to_string()),
+
        ]);
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+
}