Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
bin/ui/items: Reorganize and rename notifications
Erik Kundt committed 6 months ago
commit 1a768ecea03c9efef5ab7efdf0620b47e1aac2b2
parent 4ec2cd1ee338b76e4d239ade31894aa9a86d6ffd
4 files changed +663 -626
modified bin/commands/inbox/list.rs
@@ -34,7 +34,8 @@ use tui::ui::Column;
use tui::{BoxedAny, Channel, Exit, PageStack};

use crate::cob::inbox;
-
use crate::ui::items::{Filter, NotificationItem, NotificationItemFilter};
+
use crate::ui::items::notification::{Notification, NotificationFilter};
+
use crate::ui::items::Filter;

use self::ui::Browser;
use self::ui::BrowserProps;
@@ -65,15 +66,15 @@ pub enum AppPage {

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

impl BrowserState {
-
    pub fn notifications(&self) -> Vec<NotificationItem> {
+
    pub fn notifications(&self) -> Vec<Notification> {
        self.items
            .iter()
            .filter(|patch| self.filter.matches(patch))
@@ -104,7 +105,7 @@ impl TryFrom<&Context> for State {
        let project = doc.project()?;

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

        let mut notifications = match &context.mode.repository() {
            RepositoryMode::All => {
@@ -117,7 +118,7 @@ impl TryFrom<&Context> for State {

                    let items = inbox::all(&repo, &context.profile)?
                        .iter()
-
                        .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
+
                        .map(|notif| Notification::new(&context.profile, &repo, notif))
                        .filter_map(|item| item.ok())
                        .flatten()
                        .collect::<Vec<_>>();
@@ -132,9 +133,7 @@ impl TryFrom<&Context> for State {

                notifs
                    .iter()
-
                    .map(|notif| {
-
                        NotificationItem::new(&context.profile, &context.repository, notif)
-
                    })
+
                    .map(|notif| Notification::new(&context.profile, &context.repository, notif))
                    .filter_map(|item| item.ok())
                    .flatten()
                    .collect::<Vec<_>>()
@@ -145,7 +144,7 @@ impl TryFrom<&Context> for State {

                notifs
                    .iter()
-
                    .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
+
                    .map(|notif| Notification::new(&context.profile, &repo, notif))
                    .filter_map(|item| item.ok())
                    .flatten()
                    .collect::<Vec<_>>()
@@ -233,8 +232,8 @@ impl store::Update<Message> for State {
            }
            Message::UpdateSearch { value } => {
                self.browser.search.write(value);
-
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
-
                    .unwrap_or_default();
+
                self.browser.filter =
+
                    NotificationFilter::from_str(&self.browser.search.read()).unwrap_or_default();

                if let Some(selected) = self.browser.selected {
                    if selected > self.browser.notifications().len() {
@@ -252,8 +251,8 @@ impl store::Update<Message> for State {
            Message::CloseSearch => {
                self.browser.search.reset();
                self.browser.show_search = false;
-
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
-
                    .unwrap_or_default();
+
                self.browser.filter =
+
                    NotificationFilter::from_str(&self.browser.search.read()).unwrap_or_default();

                None
            }
modified bin/commands/inbox/list/ui.rs
@@ -25,7 +25,7 @@ use tui::ui::Column;
use tui::{BoxedAny, Selection};

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

use super::{Message, State};

@@ -38,7 +38,7 @@ pub struct BrowserProps<'a> {
    /// Table title
    header: String,
    /// Filtered notifications.
-
    notifications: Vec<NotificationItem>,
+
    notifications: Vec<Notification>,
    /// Current (selected) table index
    selected: Option<usize>,
    /// Notification statistics.
@@ -126,7 +126,7 @@ impl Browser {
                        .into()
                }))
                .content(
-
                    Table::<State, Message, NotificationItem, 9>::default()
+
                    Table::<State, Message, Notification, 9>::default()
                        .to_widget(tx.clone())
                        .on_event(|_, s, _| {
                            let (selected, _) =
@@ -291,12 +291,12 @@ fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
        span::default(&props.notifications.len().to_string()).dim(),
    ]);

-
    let state = match NotificationItemFilter::from_str(&props.search).unwrap_or_default() {
-
        NotificationItemFilter::State(state) => Some(state),
-
        NotificationItemFilter::And(filters) => filters
+
    let state = match NotificationFilter::from_str(&props.search).unwrap_or_default() {
+
        NotificationFilter::State(state) => Some(state),
+
        NotificationFilter::And(filters) => filters
            .into_iter()
            .map(|f| match f {
-
                NotificationItemFilter::State(state) => Some(state),
+
                NotificationFilter::State(state) => Some(state),
                _ => None,
            })
            .flatten()
modified bin/ui/items.rs
@@ -1,31 +1,29 @@
+
pub mod notification;
+

use std::collections::HashMap;
use std::fmt;
use std::fmt::Debug;
use std::ops::Range;
use std::str::FromStr;

-
use nom::branch::alt;
-
use nom::bytes::complete::{tag, tag_no_case, take, take_while1};
-
use nom::character::complete::{char, multispace0};
-
use nom::combinator::{map, value};
-
use nom::multi::{many0, separated_list0, separated_list1};
-
use nom::sequence::{delimited, preceded, tuple};
+
use nom::bytes::complete::{tag, take};
+
use nom::multi::separated_list0;
+
use nom::sequence::{delimited, preceded};
use nom::{IResult, Parser};

use ansi_to_tui::IntoText;

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

use radicle_surf::diff;
@@ -35,7 +33,6 @@ use radicle_cli::git::unified_diff::{Decode, HunkHeader};
use radicle_cli::terminal;
use radicle_cli::terminal::highlight::Highlighter;

-
use radicle_tui::ui::theme::style;
use ratatui::prelude::*;
use ratatui::style::{Color, Style, Stylize};
use ratatui::widgets::Cell;
@@ -85,536 +82,6 @@ impl AuthorItem {
}

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

-
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);
-
                    };
-
                    (
-
                        "issue".to_string(),
-
                        Title::new(issue.title())?,
-
                        issue.state().to_string(),
-
                    )
-
                } else if typed_id.is_patch() {
-
                    let Some(patch) = patches.get(id)? else {
-
                        // Patch could have been deleted after notification was created.
-
                        return Ok(None);
-
                    };
-
                    (
-
                        "patch".to_string(),
-
                        Title::new(patch.title())?,
-
                        patch.state().to_string(),
-
                    )
-
                } else if typed_id.is_identity() {
-
                    let Ok(identity) = Identity::get(id, repo) else {
-
                        log::error!(
-
                            target: "cli",
-
                            "Error retrieving identity {id} for notification {}", notification.id
-
                        );
-
                        return Ok(None);
-
                    };
-
                    let Some(rev) = notification
-
                        .update
-
                        .new()
-
                        .and_then(|id| identity.revision(&id))
-
                    else {
-
                        log::error!(
-
                            target: "cli",
-
                            "Error retrieving identity revision for notification {}", notification.id
-
                        );
-
                        return Ok(None);
-
                    };
-
                    (String::from("id"), rev.title.clone(), rev.state.to_string())
-
                } else {
-
                    (
-
                        typed_id.type_name.to_string(),
-
                        Title::new("")?,
-
                        "".to_string(),
-
                    )
-
                };
-

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

-
#[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!("{alias} (you)"))
-
                } else {
-
                    span::alias(alias)
-
                }
-
            }
-
            None => match &self.author.human_nid {
-
                Some(nid) => span::alias(nid).dim(),
-
                None => span::blank(),
-
            },
-
        };
-
        let timestamp = span::timestamp(&format::timestamp(&self.timestamp));
-

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

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

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

-
#[derive(Debug, Clone, PartialEq)]
-
pub enum NotificationItemFilter {
-
    State(NotificationState),
-
    Type(NotificationTypeFilter),
-
    Author(NotificationAuthorFilter),
-
    Search(String),
-
    And(Vec<NotificationItemFilter>),
-
}
-

-
impl Default for NotificationItemFilter {
-
    fn default() -> Self {
-
        Self::Type(NotificationTypeFilter::Or(vec![
-
            NotificationType::Issue,
-
            NotificationType::Patch,
-
        ]))
-
    }
-
}
-

-
#[derive(Debug, Clone, PartialEq)]
-
pub enum NotificationTypeFilter {
-
    Single(NotificationType),
-
    Or(Vec<NotificationType>),
-
}
-

-
#[derive(Debug, Clone, PartialEq)]
-
pub enum NotificationAuthorFilter {
-
    Single(Did),
-
    Or(Vec<Did>),
-
}
-

-
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 match_type = |type_name: &NotificationType| match type_name {
-
            NotificationType::Issue => matches!(&notif.kind, NotificationKindItem::Cob {
-
                        type_name,
-
                        summary: _,
-
                        status: _,
-
                        id: _,
-
                    } if type_name == "issue"),
-
            NotificationType::Patch => matches!(&notif.kind, NotificationKindItem::Cob {
-
                        type_name,
-
                        summary: _,
-
                        status: _,
-
                        id: _,
-
                    } if type_name == "patch"),
-
            NotificationType::Branch => {
-
                matches!(notif.kind, NotificationKindItem::Branch { .. })
-
            }
-
            NotificationType::Unknown => {
-
                matches!(notif.kind, NotificationKindItem::Unknown { .. })
-
            }
-
        };
-

-
        match self {
-
            NotificationItemFilter::State(state) => match state {
-
                NotificationState::Seen => notif.seen,
-
                NotificationState::Unseen => !notif.seen,
-
            },
-
            NotificationItemFilter::Type(type_filter) => match type_filter {
-
                NotificationTypeFilter::Single(type_name) => match_type(type_name),
-
                NotificationTypeFilter::Or(types) => types.iter().any(|other| match_type(other)),
-
            },
-
            NotificationItemFilter::Author(author_filter) => match author_filter {
-
                NotificationAuthorFilter::Single(author) => notif.author.nid == Some(**author),
-
                NotificationAuthorFilter::Or(authors) => authors
-
                    .iter()
-
                    .any(|other| notif.author.nid == Some(**other)),
-
            },
-
            NotificationItemFilter::Search(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,
-
                }
-
            }
-
            NotificationItemFilter::And(filters) => filters.iter().all(|f| f.matches(notif)),
-
        }
-
    }
-
}
-

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

-
    fn from_str(filter_exp: &str) -> Result<Self, Self::Err> {
-
        fn parse_did(input: &str) -> IResult<&str, Did> {
-
            match Did::from_str(input) {
-
                Ok(did) => IResult::Ok(("", did)),
-
                Err(_) => IResult::Err(nom::Err::Error(nom::error::Error::new(
-
                    input,
-
                    nom::error::ErrorKind::Verify,
-
                ))),
-
            }
-
        }
-

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

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

-
        fn parse_type(input: &str) -> IResult<&str, NotificationType> {
-
            alt((
-
                value(NotificationType::Patch, tag_no_case("patch")),
-
                value(NotificationType::Issue, tag_no_case("issue")),
-
                value(NotificationType::Branch, tag_no_case("branch")),
-
                value(NotificationType::Unknown, tag_no_case("unknown")),
-
            ))(input)
-
        }
-

-
        fn parse_type_single(input: &str) -> IResult<&str, NotificationTypeFilter> {
-
            map(parse_type, NotificationTypeFilter::Single)(input)
-
        }
-

-
        fn parse_type_or(input: &str) -> IResult<&str, NotificationTypeFilter> {
-
            map(
-
                delimited(
-
                    tuple((multispace0, char('('), multispace0)),
-
                    separated_list1(
-
                        delimited(multispace0, tag_no_case("or"), multispace0),
-
                        parse_type,
-
                    ),
-
                    tuple((multispace0, char(')'), multispace0)),
-
                ),
-
                NotificationTypeFilter::Or,
-
            )(input)
-
        }
-

-
        fn parse_type_filter(input: &str) -> IResult<&str, NotificationItemFilter> {
-
            map(
-
                preceded(
-
                    tuple((
-
                        tag_no_case("type"),
-
                        multispace0,
-
                        tag_no_case("="),
-
                        multispace0,
-
                    )),
-
                    alt((parse_type_or, parse_type_single)),
-
                ),
-
                NotificationItemFilter::Type,
-
            )(input)
-
        }
-

-
        fn parse_author_single(input: &str) -> IResult<&str, NotificationAuthorFilter> {
-
            map(parse_did, NotificationAuthorFilter::Single)(input)
-
        }
-

-
        fn parse_author_or(input: &str) -> IResult<&str, NotificationAuthorFilter> {
-
            map(
-
                delimited(
-
                    tuple((multispace0, char('('), multispace0)),
-
                    separated_list1(
-
                        delimited(multispace0, tag_no_case("or"), multispace0),
-
                        take(56_usize),
-
                    ),
-
                    tuple((multispace0, char(')'), multispace0)),
-
                ),
-
                |dids: Vec<&str>| {
-
                    NotificationAuthorFilter::Or(
-
                        dids.iter()
-
                            .filter_map(|did| Did::from_str(did).ok())
-
                            .collect::<Vec<_>>(),
-
                    )
-
                },
-
            )(input)
-
        }
-

-
        fn parse_author_filter(input: &str) -> IResult<&str, NotificationItemFilter> {
-
            map(
-
                preceded(
-
                    tuple((
-
                        tag_no_case("author"),
-
                        multispace0,
-
                        tag_no_case("="),
-
                        multispace0,
-
                    )),
-
                    alt((parse_author_single, parse_author_or)),
-
                ),
-
                NotificationItemFilter::Author,
-
            )(input)
-
        }
-

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

-
        fn parse_single_filter(input: &str) -> IResult<&str, NotificationItemFilter> {
-
            alt((
-
                parse_state_filter,
-
                parse_type_filter,
-
                parse_author_filter,
-
                parse_search_filter,
-
            ))(input)
-
        }
-

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

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

-
                    if filters.is_empty() {
-
                        return Err("No filters provided".to_string());
-
                    }
-

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

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

-
#[derive(Clone, Debug)]
pub struct IssueItem {
    /// Issue OID.
    pub id: IssueId,
@@ -2160,68 +1627,6 @@ mod tests {
    }

    #[test]
-
    fn notification_item_filter_with_type_should_succeed() -> Result<()> {
-
        let search = r#"type=patch"#;
-
        let actual = NotificationItemFilter::from_str(search)?;
-

-
        let expected =
-
            NotificationItemFilter::Type(NotificationTypeFilter::Single(NotificationType::Patch));
-

-
        assert_eq!(expected, actual);
-

-
        Ok(())
-
    }
-

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

-
        let expected = NotificationItemFilter::Author(NotificationAuthorFilter::Single(
-
            Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
-
        ));
-

-
        assert_eq!(expected, actual);
-

-
        Ok(())
-
    }
-

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

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

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

-
        Ok(())
-
    }
-

-
    #[test]
-
    fn notification_item_filter_with_all_should_succeed() -> Result<()> {
-
        let search = r#"state=seen type=(patch or issue) author=(did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB or did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx) cli"#;
-
        let actual = NotificationItemFilter::from_str(search)?;
-

-
        let expected = NotificationItemFilter::And(vec![
-
            NotificationItemFilter::State(NotificationState::Seen),
-
            NotificationItemFilter::Type(NotificationTypeFilter::Or(vec![
-
                NotificationType::Patch,
-
                NotificationType::Issue,
-
            ])),
-
            NotificationItemFilter::Author(NotificationAuthorFilter::Or(vec![
-
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
-
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
-
            ])),
-
            NotificationItemFilter::Search("cli".to_string()),
-
        ]);
-

-
        assert_eq!(expected, actual);
-

-
        Ok(())
-
    }
-

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

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

+
use radicle::cob::{ObjectId, Timestamp, Title, TypedId};
+

+
use radicle::identity::{Did, Identity};
+

+
use radicle::issue::Issues;
+
use radicle::node;
+

+
use radicle::patch::Patches;
+
use radicle::storage::git::Repository;
+
use radicle::storage::{ReadRepository, ReadStorage, RefUpdate};
+
use radicle::Profile;
+

+
use radicle_tui::ui::theme::style;
+
use ratatui::style::Stylize;
+
use ratatui::widgets::Cell;
+

+
use radicle_tui as tui;
+

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

+
use super::{AuthorItem, Filter};
+

+
#[derive(Clone, Debug)]
+
#[allow(dead_code)]
+
pub enum NotificationKind {
+
    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 NotificationKind {
+
    pub fn new(
+
        repo: &Repository,
+
        notification: &node::notifications::Notification,
+
    ) -> Result<Option<Self>, anyhow::Error> {
+
        // TODO: move out of here
+
        let issues = Issues::open(repo)?;
+
        let patches = Patches::open(repo)?;
+

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

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

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

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

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

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

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

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

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

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

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

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

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

+
#[derive(Debug, Clone, PartialEq)]
+
pub enum NotificationFilter {
+
    State(NotificationState),
+
    Type(NotificationTypeFilter),
+
    Author(NotificationAuthorFilter),
+
    Search(String),
+
    And(Vec<NotificationFilter>),
+
}
+

+
impl Default for NotificationFilter {
+
    fn default() -> Self {
+
        Self::Type(NotificationTypeFilter::Or(vec![
+
            NotificationType::Issue,
+
            NotificationType::Patch,
+
        ]))
+
    }
+
}
+

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

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

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

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

+
        let match_type = |type_name: &NotificationType| match type_name {
+
            NotificationType::Issue => matches!(&notif.kind, NotificationKind::Cob {
+
                        type_name,
+
                        summary: _,
+
                        status: _,
+
                        id: _,
+
                    } if type_name == "issue"),
+
            NotificationType::Patch => matches!(&notif.kind, NotificationKind::Cob {
+
                        type_name,
+
                        summary: _,
+
                        status: _,
+
                        id: _,
+
                    } if type_name == "patch"),
+
            NotificationType::Branch => {
+
                matches!(notif.kind, NotificationKind::Branch { .. })
+
            }
+
            NotificationType::Unknown => {
+
                matches!(notif.kind, NotificationKind::Unknown { .. })
+
            }
+
        };
+

+
        match self {
+
            NotificationFilter::State(state) => match state {
+
                NotificationState::Seen => notif.seen,
+
                NotificationState::Unseen => !notif.seen,
+
            },
+
            NotificationFilter::Type(type_filter) => match type_filter {
+
                NotificationTypeFilter::Single(type_name) => match_type(type_name),
+
                NotificationTypeFilter::Or(types) => types.iter().any(|other| match_type(other)),
+
            },
+
            NotificationFilter::Author(author_filter) => match author_filter {
+
                NotificationAuthorFilter::Single(author) => notif.author.nid == Some(**author),
+
                NotificationAuthorFilter::Or(authors) => authors
+
                    .iter()
+
                    .any(|other| notif.author.nid == Some(**other)),
+
            },
+
            NotificationFilter::Search(search) => {
+
                let summary = match &notif.kind {
+
                    NotificationKind::Cob {
+
                        type_name: _,
+
                        summary,
+
                        status: _,
+
                        id: _,
+
                    } => summary,
+
                    NotificationKind::Branch {
+
                        name: _,
+
                        summary,
+
                        status: _,
+
                        id: _,
+
                    } => summary,
+
                    NotificationKind::Unknown { refname: _ } => "",
+
                };
+
                match matcher.fuzzy_match(summary, search) {
+
                    Some(score) => score == 0 || score > 60,
+
                    _ => false,
+
                }
+
            }
+
            NotificationFilter::And(filters) => filters.iter().all(|f| f.matches(notif)),
+
        }
+
    }
+
}
+

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

+
    fn from_str(filter_exp: &str) -> Result<Self, Self::Err> {
+
        fn parse_did(input: &str) -> IResult<&str, Did> {
+
            match Did::from_str(input) {
+
                Ok(did) => IResult::Ok(("", did)),
+
                Err(_) => IResult::Err(nom::Err::Error(nom::error::Error::new(
+
                    input,
+
                    nom::error::ErrorKind::Verify,
+
                ))),
+
            }
+
        }
+

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

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

+
        fn parse_type(input: &str) -> IResult<&str, NotificationType> {
+
            alt((
+
                value(NotificationType::Patch, tag_no_case("patch")),
+
                value(NotificationType::Issue, tag_no_case("issue")),
+
                value(NotificationType::Branch, tag_no_case("branch")),
+
                value(NotificationType::Unknown, tag_no_case("unknown")),
+
            ))(input)
+
        }
+

+
        fn parse_type_single(input: &str) -> IResult<&str, NotificationTypeFilter> {
+
            map(parse_type, NotificationTypeFilter::Single)(input)
+
        }
+

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

+
        fn parse_type_filter(input: &str) -> IResult<&str, NotificationFilter> {
+
            map(
+
                preceded(
+
                    tuple((
+
                        tag_no_case("type"),
+
                        multispace0,
+
                        tag_no_case("="),
+
                        multispace0,
+
                    )),
+
                    alt((parse_type_or, parse_type_single)),
+
                ),
+
                NotificationFilter::Type,
+
            )(input)
+
        }
+

+
        fn parse_author_single(input: &str) -> IResult<&str, NotificationAuthorFilter> {
+
            map(parse_did, NotificationAuthorFilter::Single)(input)
+
        }
+

+
        fn parse_author_or(input: &str) -> IResult<&str, NotificationAuthorFilter> {
+
            map(
+
                delimited(
+
                    tuple((multispace0, char('('), multispace0)),
+
                    separated_list1(
+
                        delimited(multispace0, tag_no_case("or"), multispace0),
+
                        take(56_usize),
+
                    ),
+
                    tuple((multispace0, char(')'), multispace0)),
+
                ),
+
                |dids: Vec<&str>| {
+
                    NotificationAuthorFilter::Or(
+
                        dids.iter()
+
                            .filter_map(|did| Did::from_str(did).ok())
+
                            .collect::<Vec<_>>(),
+
                    )
+
                },
+
            )(input)
+
        }
+

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

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

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

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

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

+
                    if filters.is_empty() {
+
                        return Err("No filters provided".to_string());
+
                    }
+

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

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

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

+
    use super::*;
+

+
    #[test]
+
    fn notification_item_filter_with_type_should_succeed() -> Result<()> {
+
        let search = r#"type=patch"#;
+
        let actual = NotificationFilter::from_str(search)?;
+

+
        let expected =
+
            NotificationFilter::Type(NotificationTypeFilter::Single(NotificationType::Patch));
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+

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

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

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+

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

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

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

+
        Ok(())
+
    }
+

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

+
        let expected = NotificationFilter::And(vec![
+
            NotificationFilter::State(NotificationState::Seen),
+
            NotificationFilter::Type(NotificationTypeFilter::Or(vec![
+
                NotificationType::Patch,
+
                NotificationType::Issue,
+
            ])),
+
            NotificationFilter::Author(NotificationAuthorFilter::Or(vec![
+
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
+
            ])),
+
            NotificationFilter::Search("cli".to_string()),
+
        ]);
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+
}