Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Move all Radicle-dependent code to bin/
Merged did:key:z6MkgFq6...nBGz opened 1 year ago
30 files changed +1477 -1639 cc849ccc dba75b31
modified CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog

+
## Unreleased
+

+
### Changed
+

+
- Move all Radicle-dependent code to `bin/`
+

## [0.3.1] - 2024-06-11

### Added
added bin/cob.rs
@@ -0,0 +1,40 @@
+
use std::str::FromStr;
+

+
use anyhow::Result;
+

+
use radicle::cob::Label;
+
use radicle::prelude::Did;
+

+
pub mod inbox;
+
pub mod issue;
+
pub mod patch;
+

+
#[allow(dead_code)]
+
pub fn parse_labels(input: String) -> Result<Vec<Label>> {
+
    let mut labels = vec![];
+
    if !input.is_empty() {
+
        for name in input.split(',') {
+
            match Label::new(name.trim()) {
+
                Ok(label) => labels.push(label),
+
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse labels.")),
+
            }
+
        }
+
    }
+

+
    Ok(labels)
+
}
+

+
#[allow(dead_code)]
+
pub fn parse_assignees(input: String) -> Result<Vec<Did>> {
+
    let mut assignees = vec![];
+
    if !input.is_empty() {
+
        for did in input.split(',') {
+
            match Did::from_str(&format!("did:key:{}", did)) {
+
                Ok(did) => assignees.push(did),
+
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse assignees.")),
+
            }
+
        }
+
    }
+

+
    Ok(assignees)
+
}
added bin/cob/inbox.rs
@@ -0,0 +1,38 @@
+
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)
+
}
added bin/cob/issue.rs
@@ -0,0 +1,151 @@
+
use anyhow::Result;
+

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

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

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

+
impl Filter {
+
    pub fn with_state(mut self, state: Option<State>) -> Self {
+
        self.state = state;
+
        self
+
    }
+

+
    pub fn with_assgined(mut self, assigned: bool) -> Self {
+
        self.assigned = assigned;
+
        self
+
    }
+

+
    pub fn with_assginee(mut self, assignee: Did) -> Self {
+
        self.assignees.push(assignee);
+
        self
+
    }
+
}
+

+
impl ToString for Filter {
+
    fn to_string(&self) -> String {
+
        let mut filter = String::new();
+

+
        if let Some(state) = &self.state {
+
            filter.push_str(&format!("is:{}", state));
+
            filter.push(' ');
+
        }
+
        if self.assigned {
+
            filter.push_str("is:assigned");
+
            filter.push(' ');
+
        }
+
        if !self.assignees.is_empty() {
+
            filter.push_str("assignees:");
+
            filter.push('[');
+

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

+
                if assignees.peek().is_some() {
+
                    filter.push(',');
+
                }
+
            }
+
            filter.push(']');
+
        }
+

+
        filter
+
    }
+
}
+

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

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

+
#[allow(dead_code)]
+
pub fn find(profile: &Profile, repository: &Repository, id: &IssueId) -> Result<Option<Issue>> {
+
    let cache = profile.issues(repository)?;
+
    Ok(cache.get(id)?)
+
}
+

+
#[allow(dead_code)]
+
pub fn create<G: Signer>(
+
    profile: &Profile,
+
    repository: &Repository,
+
    signer: &G,
+
    title: String,
+
    description: String,
+
    labels: &[Label],
+
    assignees: &[Did],
+
) -> Result<IssueId> {
+
    let mut issues = profile.issues_mut(repository)?;
+
    let issue = issues.create(title, description.trim(), labels, assignees, [], signer)?;
+

+
    Ok(*issue.id())
+
}
+

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

+
    use anyhow::Result;
+
    use radicle::issue;
+

+
    use super::*;
+

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

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

+
        Ok(())
+
    }
+

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

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

+
        Ok(())
+
    }
+

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

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

+
        Ok(())
+
    }
+
}
added bin/cob/patch.rs
@@ -0,0 +1,134 @@
+
use anyhow::Result;
+

+
use radicle::cob::patch::{Patch, PatchId};
+
use radicle::identity::Did;
+
use radicle::patch::cache::Patches;
+
use radicle::patch::Status;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

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

+
impl Default for Filter {
+
    fn default() -> Self {
+
        Self {
+
            status: Some(Status::default()),
+
            authored: false,
+
            authors: vec![],
+
        }
+
    }
+
}
+

+
impl Filter {
+
    pub fn with_status(mut self, status: Option<Status>) -> Self {
+
        self.status = status;
+
        self
+
    }
+

+
    pub fn with_authored(mut self, authored: bool) -> Self {
+
        self.authored = authored;
+
        self
+
    }
+

+
    pub fn with_author(mut self, author: Did) -> Self {
+
        self.authors.push(author);
+
        self
+
    }
+
}
+

+
impl ToString for Filter {
+
    fn to_string(&self) -> String {
+
        let mut filter = String::new();
+

+
        if let Some(state) = &self.status {
+
            filter.push_str(&format!("is:{}", state));
+
            filter.push(' ');
+
        }
+
        if self.authored {
+
            filter.push_str("is:authored");
+
            filter.push(' ');
+
        }
+
        if !self.authors.is_empty() {
+
            filter.push_str("authors:");
+
            filter.push('[');
+

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

+
                if authors.peek().is_some() {
+
                    filter.push(',');
+
                }
+
            }
+
            filter.push(']');
+
        }
+

+
        filter
+
    }
+
}
+

+
pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(PatchId, Patch)>> {
+
    let cache = profile.patches(repository)?;
+
    let patches = cache.list()?;
+

+
    Ok(patches.flatten().collect())
+
}
+

+
#[allow(dead_code)]
+
pub fn find(profile: &Profile, repository: &Repository, id: &PatchId) -> Result<Option<Patch>> {
+
    let cache = profile.patches(repository)?;
+
    Ok(cache.get(id)?)
+
}
+

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

+
    use anyhow::Result;
+
    use radicle::patch;
+

+
    use super::*;
+

+
    #[test]
+
    fn patch_filter_display_with_status_should_succeed() -> Result<()> {
+
        let actual = Filter::default().with_status(Some(patch::Status::Open));
+

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

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn patch_filter_display_with_status_and_authored_should_succeed() -> Result<()> {
+
        let actual = Filter::default()
+
            .with_status(Some(patch::Status::Open))
+
            .with_authored(true);
+

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

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn patch_filter_display_with_status_and_author_should_succeed() -> Result<()> {
+
        let actual = Filter::default()
+
            .with_status(Some(patch::Status::Open))
+
            .with_author(Did::from_str(
+
                "did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V",
+
            )?);
+

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

+
        Ok(())
+
    }
+
}
modified bin/commands/inbox.rs
@@ -9,13 +9,13 @@ use anyhow::anyhow;

use radicle_tui as tui;

-
use tui::cob::inbox::{self};
-

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

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

+
use crate::cob::inbox;
+

pub const HELP: Help = Help {
    name: "inbox",
    description: "Terminal interfaces for notifications",
modified bin/commands/inbox/select.rs
@@ -22,10 +22,8 @@ use radicle::Profile;

use radicle_tui as tui;

-
use tui::cob::inbox;
use tui::store;
use tui::store::StateValue;
-
use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter};
use tui::ui::span;
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
use tui::ui::widget::text::{TextArea, TextAreaProps};
@@ -33,6 +31,9 @@ use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window
use tui::ui::widget::{ToWidget, Widget};
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;

modified bin/commands/inbox/select/ui.rs
@@ -12,7 +12,6 @@ use ratatui::text::{Line, Text};

use radicle_tui as tui;

-
use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
use tui::ui::span;
use tui::ui::widget::container::{
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
@@ -26,6 +25,7 @@ use tui::ui::widget::{RenderProps, ToWidget, View};
use tui::{BoxedAny, Selection};

use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
+
use crate::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};

use super::{Message, State};

modified bin/commands/issue.rs
@@ -15,9 +15,10 @@ use radicle_cli::terminal::{Args, Error, Help};

use radicle_tui as tui;

-
use tui::cob;
use tui::log;

+
use crate::cob;
+

pub const HELP: Help = Help {
    name: "issue",
    description: "Terminal interfaces for issues",
modified bin/commands/issue/select.rs
@@ -17,10 +17,8 @@ use radicle::Profile;

use radicle_tui as tui;

-
use tui::cob::issue;
use tui::store;
use tui::store::StateValue;
-
use tui::ui::items::{Filter, IssueItem, IssueItemFilter};
use tui::ui::span;
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
use tui::ui::widget::text::{TextArea, TextAreaProps};
@@ -29,6 +27,9 @@ use tui::ui::widget::{ToWidget, Widget};

use tui::{BoxedAny, Channel, Exit, PageStack};

+
use crate::cob::issue;
+
use crate::ui::items::{Filter, IssueItem, IssueItemFilter};
+

use self::ui::{Browser, BrowserProps};

use super::common::Mode;
modified bin/commands/issue/select/ui.rs
@@ -14,7 +14,6 @@ use ratatui::text::{Line, Text};

use radicle_tui as tui;

-
use tui::ui::items::{IssueItem, IssueItemFilter};
use tui::ui::span;
use tui::ui::widget;
use tui::ui::widget::container::{
@@ -30,6 +29,7 @@ use tui::{BoxedAny, Selection};

use crate::tui_issue::common::IssueOperation;
use crate::tui_issue::common::Mode;
+
use crate::ui::items::{IssueItem, IssueItemFilter};

use super::{Message, State};

modified bin/commands/patch.rs
@@ -9,13 +9,16 @@ use anyhow::anyhow;

use radicle::identity::RepoId;
use radicle::patch::Status;
+

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

use radicle_tui as tui;

-
use tui::cob::patch::{self, Filter};
use tui::log;

-
use radicle_cli::terminal;
-
use radicle_cli::terminal::args::{Args, Error, Help};
+
use crate::cob::patch;
+
use crate::cob::patch::Filter;

pub const HELP: Help = Help {
    name: "patch",
modified bin/commands/patch/select.rs
@@ -16,9 +16,7 @@ use ratatui::style::Stylize;
use ratatui::text::{Line, Span, Text};

use termion::event::Key;
-
use tui::cob::patch;
use tui::store;
-
use tui::ui::items::{Filter, PatchItem, PatchItemFilter};
use tui::ui::span;
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
use tui::ui::widget::text::{TextArea, TextAreaProps};
@@ -31,6 +29,9 @@ use self::ui::{Browser, BrowserProps};

use super::common::Mode;

+
use crate::cob::patch;
+
use crate::ui::items::{Filter, PatchItem, PatchItemFilter};
+

type Selection = tui::Selection<PatchId>;

pub struct Context {
modified bin/commands/patch/select/ui.rs
@@ -16,7 +16,6 @@ use radicle::patch::Status;

use radicle_tui as tui;

-
use tui::ui::items::{PatchItem, PatchItemFilter};
use tui::ui::span;
use tui::ui::widget;
use tui::ui::widget::container::{
@@ -32,6 +31,7 @@ use tui::{BoxedAny, Selection};

use crate::tui_patch::common::Mode;
use crate::tui_patch::common::PatchOperation;
+
use crate::ui::items::{PatchItem, PatchItemFilter};

use super::{Message, State};

added bin/git.rs
@@ -0,0 +1,20 @@
+
use radicle::git;
+
use radicle::git::Oid;
+

+
/// Get the diff stats between two commits.
+
/// Should match the default output of `git diff <old> <new> --stat` exactly.
+
pub fn diff_stats(
+
    repo: &git::raw::Repository,
+
    old: &Oid,
+
    new: &Oid,
+
) -> Result<git::raw::DiffStats, git::raw::Error> {
+
    let old = repo.find_commit(**old)?;
+
    let new = repo.find_commit(**new)?;
+
    let old_tree = old.tree()?;
+
    let new_tree = new.tree()?;
+
    let mut diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
+
    let mut find_opts = git::raw::DiffFindOptions::new();
+

+
    diff.find_similar(Some(&mut find_opts))?;
+
    diff.stats()
+
}
modified bin/main.rs
@@ -1,4 +1,7 @@
+
mod cob;
mod commands;
+
mod git;
+
mod ui;

use std::ffi::OsString;
use std::io;
added bin/ui.rs
@@ -0,0 +1,2 @@
+
pub mod format;
+
pub mod items;
added bin/ui/format.rs
@@ -0,0 +1,98 @@
+
use std::time::{SystemTime, UNIX_EPOCH};
+

+
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 {
+
    format!("{:.7}", oid.into())
+
}
+

+
/// Format a COB id.
+
pub fn cob(id: &ObjectId) -> String {
+
    format!("{:.7}", id.to_string())
+
}
+

+
/// Format a DID.
+
pub fn did(did: &Did) -> String {
+
    let nid = did.as_key().to_human();
+
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
+
}
+

+
/// Format a timestamp.
+
pub fn timestamp(time: &Timestamp) -> String {
+
    let fmt = timeago::Formatter::new();
+
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
+
    let now = Timestamp::from_secs(now.as_secs());
+
    let duration = std::time::Duration::from_secs(now.as_secs() - time.as_secs());
+

+
    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_str(", ");
+
        }
+
    }
+
    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 bin/ui/items.rs
@@ -0,0 +1,957 @@
+
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;
+
use radicle::issue::{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 radicle_tui as tui;
+
use tui::ui::widget::list::ToRow;
+

+
use super::super::git;
+
use super::format;
+
use tui::ui::span;
+
use tui::ui::theme::style;
+

+
pub trait Filter<T> {
+
    fn matches(&self, item: &T) -> bool;
+
}
+

+
#[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 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)));
+

+
        Self {
+
            nid,
+
            human_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<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(),
+
            ),
+
            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(" ● ")
+
        };
+
        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!("{} (you)", alias))
+
                } 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(),
+
            name.into(),
+
            kind_id.into(),
+
            summary.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()
+
    }
+
}
+

+
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 = (!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::new(Some(*issue.author().id), profile),
+
            labels: issue.labels().cloned().collect(),
+
            assignees: issue
+
                .assignees()
+
                .map(|did| AuthorItem::new(Some(**did), profile))
+
                .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)
+
                }
+
            }
+
            None => match &self.author.human_nid {
+
                Some(nid) => span::alias(nid).dim(),
+
                None => span::blank(),
+
            },
+
        };
+
        let did = match &self.author.human_nid {
+
            Some(nid) => span::alias(nid).dim(),
+
            None => span::blank(),
+
        };
+
        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
+
    }
+
}
+

+
impl Filter<IssueItem> for IssueItemFilter {
+
    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::new(Some(*patch.author().id), profile),
+
            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)
+
                }
+
            }
+
            None => match &self.author.human_nid {
+
                Some(nid) => span::alias(nid).dim(),
+
                None => span::blank(),
+
            },
+
        };
+
        let did = match &self.author.human_nid {
+
            Some(nid) => span::alias(nid).dim(),
+
            None => span::blank(),
+
        };
+

+
        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
+
    }
+
}
+

+
impl Filter<PatchItem> for PatchItemFilter {
+
    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(())
+
    }
+
}
deleted src/cob.rs
@@ -1,38 +0,0 @@
-
use std::str::FromStr;
-

-
use anyhow::Result;
-

-
use radicle::cob::Label;
-
use radicle::prelude::Did;
-

-
pub mod inbox;
-
pub mod issue;
-
pub mod patch;
-

-
pub fn parse_labels(input: String) -> Result<Vec<Label>> {
-
    let mut labels = vec![];
-
    if !input.is_empty() {
-
        for name in input.split(',') {
-
            match Label::new(name.trim()) {
-
                Ok(label) => labels.push(label),
-
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse labels.")),
-
            }
-
        }
-
    }
-

-
    Ok(labels)
-
}
-

-
pub fn parse_assignees(input: String) -> Result<Vec<Did>> {
-
    let mut assignees = vec![];
-
    if !input.is_empty() {
-
        for did in input.split(',') {
-
            match Did::from_str(&format!("did:key:{}", did)) {
-
                Ok(did) => assignees.push(did),
-
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse assignees.")),
-
            }
-
        }
-
    }
-

-
    Ok(assignees)
-
}
deleted src/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)
-
}
deleted src/cob/issue.rs
@@ -1,149 +0,0 @@
-
use anyhow::Result;
-

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

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

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

-
impl Filter {
-
    pub fn with_state(mut self, state: Option<State>) -> Self {
-
        self.state = state;
-
        self
-
    }
-

-
    pub fn with_assgined(mut self, assigned: bool) -> Self {
-
        self.assigned = assigned;
-
        self
-
    }
-

-
    pub fn with_assginee(mut self, assignee: Did) -> Self {
-
        self.assignees.push(assignee);
-
        self
-
    }
-
}
-

-
impl ToString for Filter {
-
    fn to_string(&self) -> String {
-
        let mut filter = String::new();
-

-
        if let Some(state) = &self.state {
-
            filter.push_str(&format!("is:{}", state));
-
            filter.push(' ');
-
        }
-
        if self.assigned {
-
            filter.push_str("is:assigned");
-
            filter.push(' ');
-
        }
-
        if !self.assignees.is_empty() {
-
            filter.push_str("assignees:");
-
            filter.push('[');
-

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

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

-
        filter
-
    }
-
}
-

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

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

-
pub fn find(profile: &Profile, repository: &Repository, id: &IssueId) -> Result<Option<Issue>> {
-
    let cache = profile.issues(repository)?;
-
    Ok(cache.get(id)?)
-
}
-

-
pub fn create<G: Signer>(
-
    profile: &Profile,
-
    repository: &Repository,
-
    signer: &G,
-
    title: String,
-
    description: String,
-
    labels: &[Label],
-
    assignees: &[Did],
-
) -> Result<IssueId> {
-
    let mut issues = profile.issues_mut(repository)?;
-
    let issue = issues.create(title, description.trim(), labels, assignees, [], signer)?;
-

-
    Ok(*issue.id())
-
}
-

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

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

-
    use super::*;
-

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

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

-
        Ok(())
-
    }
-

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

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

-
        Ok(())
-
    }
-

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

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

-
        Ok(())
-
    }
-
}
deleted src/cob/patch.rs
@@ -1,133 +0,0 @@
-
use anyhow::Result;
-

-
use radicle::cob::patch::{Patch, PatchId};
-
use radicle::identity::Did;
-
use radicle::patch::cache::Patches;
-
use radicle::patch::Status;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

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

-
impl Default for Filter {
-
    fn default() -> Self {
-
        Self {
-
            status: Some(Status::default()),
-
            authored: false,
-
            authors: vec![],
-
        }
-
    }
-
}
-

-
impl Filter {
-
    pub fn with_status(mut self, status: Option<Status>) -> Self {
-
        self.status = status;
-
        self
-
    }
-

-
    pub fn with_authored(mut self, authored: bool) -> Self {
-
        self.authored = authored;
-
        self
-
    }
-

-
    pub fn with_author(mut self, author: Did) -> Self {
-
        self.authors.push(author);
-
        self
-
    }
-
}
-

-
impl ToString for Filter {
-
    fn to_string(&self) -> String {
-
        let mut filter = String::new();
-

-
        if let Some(state) = &self.status {
-
            filter.push_str(&format!("is:{}", state));
-
            filter.push(' ');
-
        }
-
        if self.authored {
-
            filter.push_str("is:authored");
-
            filter.push(' ');
-
        }
-
        if !self.authors.is_empty() {
-
            filter.push_str("authors:");
-
            filter.push('[');
-

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

-
                if authors.peek().is_some() {
-
                    filter.push(',');
-
                }
-
            }
-
            filter.push(']');
-
        }
-

-
        filter
-
    }
-
}
-

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

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

-
pub fn find(profile: &Profile, repository: &Repository, id: &PatchId) -> Result<Option<Patch>> {
-
    let cache = profile.patches(repository)?;
-
    Ok(cache.get(id)?)
-
}
-

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

-
    use anyhow::Result;
-
    use radicle::patch;
-

-
    use super::*;
-

-
    #[test]
-
    fn patch_filter_display_with_status_should_succeed() -> Result<()> {
-
        let actual = Filter::default().with_status(Some(patch::Status::Open));
-

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

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn patch_filter_display_with_status_and_authored_should_succeed() -> Result<()> {
-
        let actual = Filter::default()
-
            .with_status(Some(patch::Status::Open))
-
            .with_authored(true);
-

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

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn patch_filter_display_with_status_and_author_should_succeed() -> Result<()> {
-
        let actual = Filter::default()
-
            .with_status(Some(patch::Status::Open))
-
            .with_author(Did::from_str(
-
                "did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V",
-
            )?);
-

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

-
        Ok(())
-
    }
-
}
deleted src/context.rs
@@ -1,184 +0,0 @@
-
use std::fmt::Display;
-

-
use radicle::cob::issue::{Issue, IssueId};
-
use radicle::cob::patch::{Patch, PatchId};
-
use radicle::crypto::ssh::keystore::MemorySigner;
-
use radicle::crypto::Signer;
-
use radicle::identity::{Project, RepoId};
-
use radicle::node::notifications::Notification;
-
use radicle::profile::env::RAD_PASSPHRASE;
-
use radicle::storage::git::Repository;
-
use radicle::storage::{ReadRepository, ReadStorage};
-
use radicle::Profile;
-

-
use radicle_cli::terminal::io::PassphraseValidator;
-
use radicle_term as term;
-
use term::{passphrase, spinner};
-

-
use super::cob::inbox;
-

-
/// Git revision parameter. Supports extended SHA-1 syntax.
-
#[derive(Debug, Clone, PartialEq, Eq)]
-

-
pub struct Rev(String);
-

-
impl From<String> for Rev {
-
    fn from(value: String) -> Self {
-
        Rev(value)
-
    }
-
}
-

-
impl Display for Rev {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        write!(f, "{}", self.0)
-
    }
-
}
-

-
/// Application context that holds all the project data that are
-
/// needed to render it.
-
pub struct Context {
-
    profile: Profile,
-
    rid: RepoId,
-
    project: Project,
-
    repository: Repository,
-
    issues: Option<Vec<(IssueId, Issue)>>,
-
    patches: Option<Vec<(PatchId, Patch)>>,
-
    notifications: Vec<Notification>,
-
    signer: Option<Box<dyn Signer>>,
-
}
-

-
impl Context {
-
    pub fn new(profile: Profile, rid: RepoId) -> Result<Self, anyhow::Error> {
-
        let repository = profile.storage.repository(rid).unwrap();
-
        let project = repository.identity_doc()?.project()?;
-
        let notifications = inbox::all(&repository, &profile)?;
-

-
        let issues = None;
-
        let patches = None;
-
        let signer = None;
-

-
        Ok(Self {
-
            profile,
-
            rid,
-
            project,
-
            repository,
-
            issues,
-
            patches,
-
            notifications,
-
            signer,
-
        })
-
    }
-

-
    pub fn with_issues(mut self) -> Self {
-
        use super::cob::issue;
-
        self.issues = Some(issue::all(&self.profile, &self.repository).unwrap_or_default());
-
        self
-
    }
-

-
    pub fn with_patches(mut self) -> Self {
-
        use super::cob::patch;
-
        self.patches = Some(patch::all(&self.profile, &self.repository).unwrap_or_default());
-
        self
-
    }
-

-
    pub fn with_signer(mut self) -> Self {
-
        self.signer = signer(&self.profile).ok();
-
        self
-
    }
-

-
    pub fn profile(&self) -> &Profile {
-
        &self.profile
-
    }
-

-
    pub fn rid(&self) -> &RepoId {
-
        &self.rid
-
    }
-

-
    pub fn project(&self) -> &Project {
-
        &self.project
-
    }
-

-
    pub fn repository(&self) -> &Repository {
-
        &self.repository
-
    }
-

-
    pub fn issues(&self) -> &Option<Vec<(IssueId, Issue)>> {
-
        &self.issues
-
    }
-

-
    pub fn patches(&self) -> &Option<Vec<(PatchId, Patch)>> {
-
        &self.patches
-
    }
-

-
    pub fn notifications(&self) -> &Vec<Notification> {
-
        &self.notifications
-
    }
-

-
    #[allow(clippy::borrowed_box)]
-
    pub fn signer(&self) -> &Option<Box<dyn Signer>> {
-
        &self.signer
-
    }
-

-
    pub fn reload_patches(&mut self) {
-
        use super::cob::patch;
-
        self.patches = Some(patch::all(&self.profile, &self.repository).unwrap_or_default());
-
    }
-

-
    pub fn reload_issues(&mut self) {
-
        use super::cob::issue;
-
        self.issues = Some(issue::all(&self.profile, &self.repository).unwrap_or_default());
-
    }
-
}
-

-
// /// Validates secret key passphrases.
-
// #[derive(Clone)]
-
// pub struct PassphraseValidator {
-
//     keystore: Keystore,
-
// }
-

-
// impl PassphraseValidator {
-
//     /// Create a new validator.
-
//     pub fn new(keystore: Keystore) -> Self {
-
//         Self { keystore }
-
//     }
-
// }
-

-
// impl inquire::validator::StringValidator for PassphraseValidator {
-
//     fn validate(
-
//         &self,
-
//         input: &str,
-
//     ) -> Result<validator::Validation, inquire::error::CustomUserError> {
-
//         let passphrase = Passphrase::from(input.to_owned());
-
//         if self.keystore.is_valid_passphrase(&passphrase)? {
-
//             Ok(validator::Validation::Valid)
-
//         } else {
-
//             Ok(validator::Validation::Invalid(
-
//                 validator::ErrorMessage::from("Invalid passphrase, please try again"),
-
//             ))
-
//         }
-
//     }
-
// }
-

-
/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user,
-
/// if we're connected to a TTY.
-
pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
-
    if let Ok(signer) = profile.signer() {
-
        return Ok(signer);
-
    }
-
    let validator = PassphraseValidator::new(profile.keystore.clone());
-
    let passphrase = match passphrase(validator) {
-
        Ok(p) => p,
-
        Err(inquire::InquireError::NotTTY) => {
-
            return Err(anyhow::anyhow!(
-
                "running in non-interactive mode, please set `{RAD_PASSPHRASE}` to unseal your key",
-
            ));
-
        }
-
        Err(e) => return Err(e.into()),
-
    };
-
    let spinner = spinner("Unsealing key...");
-
    let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;
-

-
    spinner.finish();
-

-
    Ok(signer.boxed())
-
}
deleted src/git.rs
@@ -1,20 +0,0 @@
-
use radicle::git;
-
use radicle::git::Oid;
-

-
/// Get the diff stats between two commits.
-
/// Should match the default output of `git diff <old> <new> --stat` exactly.
-
pub fn diff_stats(
-
    repo: &git::raw::Repository,
-
    old: &Oid,
-
    new: &Oid,
-
) -> Result<git::raw::DiffStats, git::raw::Error> {
-
    let old = repo.find_commit(**old)?;
-
    let new = repo.find_commit(**new)?;
-
    let old_tree = old.tree()?;
-
    let new_tree = new.tree()?;
-
    let mut diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
-
    let mut find_opts = git::raw::DiffFindOptions::new();
-

-
    diff.find_similar(Some(&mut find_opts))?;
-
    diff.stats()
-
}
modified src/lib.rs
@@ -1,7 +1,4 @@
-
pub mod cob;
-
pub mod context;
pub mod event;
-
pub mod git;
pub mod log;
pub mod store;
pub mod task;
modified src/ui.rs
@@ -1,6 +1,4 @@
pub mod ext;
-
pub mod format;
-
pub mod items;
pub mod layout;
pub mod span;
pub mod theme;
deleted src/ui/format.rs
@@ -1,98 +0,0 @@
-
use std::time::{SystemTime, UNIX_EPOCH};
-

-
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 {
-
    format!("{:.7}", oid.into())
-
}
-

-
/// Format a COB id.
-
pub fn cob(id: &ObjectId) -> String {
-
    format!("{:.7}", id.to_string())
-
}
-

-
/// Format a DID.
-
pub fn did(did: &Did) -> String {
-
    let nid = did.as_key().to_human();
-
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
-
}
-

-
/// Format a timestamp.
-
pub fn timestamp(time: &Timestamp) -> String {
-
    let fmt = timeago::Formatter::new();
-
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
-
    let now = Timestamp::from_secs(now.as_secs());
-
    let duration = std::time::Duration::from_secs(now.as_secs() - time.as_secs());
-

-
    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_str(", ");
-
        }
-
    }
-
    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
-
}
deleted src/ui/items.rs
@@ -1,957 +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::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::{format, span};
-

-
/// Needs to be implemented for items that are supposed to be rendered in tables.
-
pub trait ToRow<const W: usize> {
-
    fn to_row(&self) -> [Cell; W];
-
}
-

-
pub trait Filter<T> {
-
    fn matches(&self, item: &T) -> bool;
-
}
-

-
#[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 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)));
-

-
        Self {
-
            nid,
-
            human_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<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(),
-
            ),
-
            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(" ● ")
-
        };
-
        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!("{} (you)", alias))
-
                } 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(),
-
            name.into(),
-
            kind_id.into(),
-
            summary.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()
-
    }
-
}
-

-
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 = (!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::new(Some(*issue.author().id), profile),
-
            labels: issue.labels().cloned().collect(),
-
            assignees: issue
-
                .assignees()
-
                .map(|did| AuthorItem::new(Some(**did), profile))
-
                .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)
-
                }
-
            }
-
            None => match &self.author.human_nid {
-
                Some(nid) => span::alias(nid).dim(),
-
                None => span::blank(),
-
            },
-
        };
-
        let did = match &self.author.human_nid {
-
            Some(nid) => span::alias(nid).dim(),
-
            None => span::blank(),
-
        };
-
        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
-
    }
-
}
-

-
impl Filter<IssueItem> for IssueItemFilter {
-
    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::new(Some(*patch.author().id), profile),
-
            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)
-
                }
-
            }
-
            None => match &self.author.human_nid {
-
                Some(nid) => span::alias(nid).dim(),
-
                None => span::blank(),
-
            },
-
        };
-
        let did = match &self.author.human_nid {
-
            Some(nid) => span::alias(nid).dim(),
-
            None => span::blank(),
-
        };
-

-
        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
-
    }
-
}
-

-
impl Filter<PatchItem> for PatchItemFilter {
-
    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(())
-
    }
-
}
modified src/ui/widget/list.rs
@@ -1,7 +1,7 @@
use std::cmp;
use std::marker::PhantomData;

-
use ratatui::widgets::Row;
+
use ratatui::widgets::{Cell, Row};
use ratatui::Frame;
use termion::event::Key;

@@ -10,13 +10,17 @@ use ratatui::style::Stylize;
use ratatui::text::Text;
use ratatui::widgets::TableState;

-
use crate::ui::items::ToRow;
use crate::ui::theme::style;
use crate::ui::{layout, span};

use super::{container::Column, RenderProps, View};
use super::{ViewProps, ViewState};

+
/// Needs to be implemented for items that are supposed to be rendered in tables.
+
pub trait ToRow<const W: usize> {
+
    fn to_row(&self) -> [Cell; W];
+
}
+

#[derive(Clone, Debug)]
pub struct TableProps<'a, R, const W: usize>
where