use std::collections::HashMap; 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::thread::{Comment, CommentId}; 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::text::{Line, Text}; use ratatui::widgets::Cell; use tui_tree_widget::TreeItem; use radicle_tui as tui; use tui::ui::span; use tui::ui::theme::style; use tui::ui::{ToRow, ToTree}; use super::super::git; use super::format; pub trait Filter { fn matches(&self, item: &T) -> bool; } #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuthorItem { pub nid: Option, pub human_nid: Option, pub alias: Option, pub you: bool, } impl AuthorItem { pub fn new(nid: Option, 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)] #[allow(dead_code)] pub enum NotificationKindItem { Branch { name: String, summary: String, status: String, id: Option, }, Cob { type_name: String, summary: String, status: String, id: Option, }, Unknown { refname: String, }, } impl NotificationKindItem { pub fn new( repo: &Repository, notification: &Notification, ) -> Result, anyhow::Error> { // TODO: move out of here let issues = Issues::open(repo)?; let patches = Patches::open(repo)?; match ¬ification.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, 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(), summary.into(), name.into(), kind_id.into(), type_name.into(), status.into(), author.into(), timestamp.into(), ] } } #[derive(Clone, Debug, Eq, PartialEq)] pub enum NotificationType { Patch, Issue, Branch, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum NotificationState { Seen, Unseen, } #[derive(Clone, Default, Debug, Eq, PartialEq)] pub struct NotificationItemFilter { state: Option, type_name: Option, authors: Vec, search: Option, } impl NotificationItemFilter { pub fn state(&self) -> Option { self.state.clone() } } impl Filter 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!(¬if.kind, NotificationKindItem::Cob { type_name, summary: _, status: _, id: _, } if type_name == "patch"), Some(NotificationType::Issue) => matches!(¬if.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 ¬if.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 { 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