From f23cc04759a543247c3b48d426291a1d30d5c19b Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Wed, 7 Feb 2024 19:05:43 +0100 Subject: [PATCH] inbox: Show notifications --- bin/commands/inbox/select.rs | 1 + bin/commands/inbox/select/event.rs | 2 +- bin/commands/inbox/select/page.rs | 10 +- bin/commands/inbox/select/ui.rs | 75 ++++++++++-- src/cob/inbox.rs | 21 ++++ src/context.rs | 12 ++ src/ui/cob.rs | 190 +++++++++++++++++++++++++++-- src/ui/widget/label.rs | 4 + 8 files changed, 291 insertions(+), 24 deletions(-) diff --git a/bin/commands/inbox/select.rs b/bin/commands/inbox/select.rs index e9772317..ec93acdd 100644 --- a/bin/commands/inbox/select.rs +++ b/bin/commands/inbox/select.rs @@ -60,6 +60,7 @@ impl Serialize for PatchId { pub enum Mode { #[default] Operation, + #[allow(dead_code)] Id, } diff --git a/bin/commands/inbox/select/event.rs b/bin/commands/inbox/select/event.rs index 30a5b8b7..79f8d818 100644 --- a/bin/commands/inbox/select/event.rs +++ b/bin/commands/inbox/select/event.rs @@ -36,7 +36,7 @@ impl tuirealm::Component for Widget { fn on(&mut self, event: Event) -> Option { let mut submit = || -> Option { match self.perform(Cmd::Submit) { - CmdResult::Submit(state) => None, + CmdResult::Submit(_) => None, _ => None, } }; diff --git a/bin/commands/inbox/select/page.rs b/bin/commands/inbox/select/page.rs index 07b37858..6fc87922 100644 --- a/bin/commands/inbox/select/page.rs +++ b/bin/commands/inbox/select/page.rs @@ -3,16 +3,16 @@ use std::collections::HashMap; use anyhow::Result; use tui::ui::state::ItemState; -use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, Sub, SubClause}; +use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent}; use radicle_tui as tui; use tui::cob::inbox::Filter; use tui::context::Context; +use tui::ui::layout; use tui::ui::theme::Theme; use tui::ui::widget::context::{Progress, Shortcuts}; use tui::ui::widget::Widget; -use tui::ui::{layout, subscription}; use tui::ViewPage; use super::{ui, Application, Cid, ListCid, Message}; @@ -84,7 +84,7 @@ impl ViewPage for ListView { context: &Context, theme: &Theme, ) -> Result<()> { - let browser = ui::operation_select(theme, context, self.filter.clone(), None).to_boxed(); + let browser = ui::operation_select(theme, context, self.filter.clone()).to_boxed(); self.shortcuts = browser.as_ref().shortcuts(); app.remount(Cid::List(ListCid::NotificationBrowser), browser, vec![])?; @@ -136,11 +136,11 @@ impl ViewPage for ListView { app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts); } - fn subscribe(&self, app: &mut Application) -> Result<()> { + fn subscribe(&self, _app: &mut Application) -> Result<()> { Ok(()) } - fn unsubscribe(&self, app: &mut Application) -> Result<()> { + fn unsubscribe(&self, _app: &mut Application) -> Result<()> { Ok(()) } } diff --git a/bin/commands/inbox/select/ui.rs b/bin/commands/inbox/select/ui.rs index f8d5fc0e..da57cb4a 100644 --- a/bin/commands/inbox/select/ui.rs +++ b/bin/commands/inbox/select/ui.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; -use radicle::issue::{Issue, IssueId}; +use radicle::node::notifications::Notification; +use tui::ui::cob::NotificationItem; +use tui::ui::widget::list::{ColumnWidth, Table}; use tuirealm::command::{Cmd, CmdResult}; use tuirealm::tui::layout::Rect; use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State}; @@ -17,21 +19,74 @@ use tui::ui::widget::{Widget, WidgetComponent}; use super::ListCid; -pub struct NotificationBrowser {} +pub struct NotificationBrowser { + items: Vec, + table: Widget>, +} + +impl NotificationBrowser { + pub fn new(theme: &Theme, context: &Context, selected: Option) -> Self { + let header = [ + label::header(""), + label::header(" ● "), + label::header("Type"), + label::header("Summary"), + label::header("ID"), + label::header("Status"), + label::header("Updated"), + ]; + let widths = [ + ColumnWidth::Fixed(5), + ColumnWidth::Fixed(3), + ColumnWidth::Fixed(6), + ColumnWidth::Grow, + ColumnWidth::Fixed(15), + ColumnWidth::Fixed(10), + ColumnWidth::Fixed(15), + ]; + + let mut items = vec![]; + for notification in context.notifications() { + if let Ok(item) = + NotificationItem::try_from((context.repository(), notification.clone())) + { + items.push(item); + } + } + + let selected = match selected { + Some(notif) => { + Some(NotificationItem::try_from((context.repository(), notif.clone())).unwrap()) + } + _ => items.first().cloned(), + }; + + let table = Widget::new(Table::new(&items, selected, header, widths, theme.clone())); + + Self { items, table } + } + + pub fn items(&self) -> &Vec { + &self.items + } +} impl WidgetComponent for NotificationBrowser { fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) { let focus = properties .get_or(Attribute::Focus, AttrValue::Flag(false)) .unwrap_flag(); + + self.table.attr(Attribute::Focus, AttrValue::Flag(focus)); + self.table.view(frame, area); } fn state(&self) -> State { - State::None + self.table.state() } fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult { - CmdResult::None + self.table.perform(cmd) } } @@ -81,18 +136,18 @@ impl WidgetComponent for OperationSelect { pub fn operation_select( theme: &Theme, context: &Context, - filter: Filter, - selected: Option<(IssueId, Issue)>, + _filter: Filter, + selected: Option, ) -> Widget { - let browser = Widget::new(NotificationBrowser {}); + let browser = Widget::new(NotificationBrowser::new(theme, context, selected)); Widget::new(OperationSelect::new(theme.clone(), browser)) } pub fn browse_context( - context: &Context, + _context: &Context, _theme: &Theme, - filter: Filter, + _filter: Filter, progress: Progress, ) -> Widget { let context = label::reversable("/").style(style::magenta_reversed()); @@ -101,7 +156,7 @@ pub fn browse_context( let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed()); let spacer = label::default(""); - let divider = label::default(" | "); + let _divider = label::default(" | "); let context_bar = ContextBar::new( label::group(&[context]), diff --git a/src/cob/inbox.rs b/src/cob/inbox.rs index e0e90062..7f0932c4 100644 --- a/src/cob/inbox.rs +++ b/src/cob/inbox.rs @@ -1,2 +1,23 @@ +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 {} + +pub fn all(repository: &Repository, profile: &Profile) -> Result> { + let all = profile + .notifications_mut()? + .by_repo(&repository.id, "timestamp")? + .collect::>(); + + let mut notifications = vec![]; + for n in all { + let n = n?; + notifications.push(n); + } + + Ok(notifications) +} diff --git a/src/context.rs b/src/context.rs index 6170a727..2c7769c7 100644 --- a/src/context.rs +++ b/src/context.rs @@ -8,6 +8,8 @@ use radicle::identity::{Project, RepoId}; use radicle::profile::env::RAD_PASSPHRASE; use radicle::storage::git::Repository; use radicle::storage::{ReadRepository, ReadStorage}; +use radicle::node::notifications::*; + use radicle::Profile; use radicle_term as term; @@ -15,6 +17,8 @@ use term::{passphrase, spinner, Passphrase}; use inquire::validator; +use crate::cob::inbox; + /// Git revision parameter. Supports extended SHA-1 syntax. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Rev(String); @@ -40,6 +44,7 @@ pub struct Context { repository: Repository, issues: Option>, patches: Option>, + notifications: Vec, signer: Option>, } @@ -47,6 +52,8 @@ impl Context { pub fn new(profile: Profile, rid: RepoId) -> Result { 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; @@ -58,6 +65,7 @@ impl Context { repository, issues, patches, + notifications, signer, }) } @@ -103,6 +111,10 @@ impl Context { &self.patches } + pub fn notifications(&self) -> &Vec { + &self.notifications + } + #[allow(clippy::borrowed_box)] pub fn signer(&self) -> &Option> { &self.signer diff --git a/src/ui/cob.rs b/src/ui/cob.rs index 2be21522..03501644 100644 --- a/src/ui/cob.rs +++ b/src/ui/cob.rs @@ -1,21 +1,24 @@ pub mod format; +use anyhow::anyhow; + use radicle_surf; use tuirealm::props::{Color, Style}; use tuirealm::tui::text::Line; use tuirealm::tui::widgets::Cell; -use radicle::node::{Alias, AliasStore}; - -use radicle::prelude::Did; -use radicle::storage::git::Repository; -use radicle::storage::{Oid, ReadRepository}; -use radicle::Profile; - use radicle::cob::issue::{self, Issue, IssueId}; use radicle::cob::patch::{self, Patch, PatchId}; -use radicle::cob::{Label, Timestamp}; +use radicle::cob::{Label, ObjectId, Timestamp}; +use radicle::issue::Issues; +use radicle::node::notifications::{Notification, NotificationId, NotificationKind}; +use radicle::node::{Alias, AliasStore}; +use radicle::patch::Patches; +use radicle::prelude::Did; +use radicle::storage::git::Repository; +use radicle::storage::{Oid, ReadRepository, RefUpdate}; +use radicle::{cob, Profile}; use crate::ui::theme::Theme; use crate::ui::widget::list::{ListItem, TableItem}; @@ -380,6 +383,177 @@ impl PartialEq for IssueItem { } } +////////////////////////////////////////////////////// +#[derive(Clone)] +pub enum NotificationKindItem { + Branch { + name: String, + summary: String, + status: String, + id: Option, + }, + Cob { + type_name: String, + summary: String, + status: String, + id: Option, + }, +} + +impl TryFrom<(&Repository, NotificationKind, RefUpdate)> for NotificationKindItem { + type Error = anyhow::Error; + + fn try_from(value: (&Repository, NotificationKind, RefUpdate)) -> Result { + let (repo, kind, update) = value; + let issues = Issues::open(repo)?; + let patches = Patches::open(repo)?; + + match kind { + NotificationKind::Branch { name } => { + let (head, message) = if let Some(head) = update.new() { + let message = repo.commit(head)?.summary().unwrap_or_default().to_owned(); + (Some(head), message) + } else { + (None, String::new()) + }; + let status = match update { + RefUpdate::Updated { .. } => "updated", + RefUpdate::Created { .. } => "created", + RefUpdate::Deleted { .. } => "deleted", + RefUpdate::Skipped { .. } => "skipped", + }; + + Ok(NotificationKindItem::Branch { + name: name.to_string(), + summary: message, + status: status.to_string(), + id: head.map(ObjectId::from), + }) + } + NotificationKind::Cob { type_name, id } => { + let (category, summary) = if type_name == *cob::issue::TYPENAME { + let issue = issues.get(&id)?.ok_or(anyhow!("missing"))?; + (String::from("issue"), issue.title().to_owned()) + } else if type_name == *cob::patch::TYPENAME { + let patch = patches.get(&id)?.ok_or(anyhow!("missing"))?; + (String::from("patch"), patch.title().to_owned()) + } else { + (type_name.to_string(), "".to_owned()) + }; + let status = match update { + RefUpdate::Updated { .. } => "updated", + RefUpdate::Created { .. } => "opened", + RefUpdate::Deleted { .. } => "deleted", + RefUpdate::Skipped { .. } => "skipped", + }; + + Ok(NotificationKindItem::Cob { + type_name: category.to_string(), + summary: summary.to_string(), + status: status.to_string(), + id: Some(id), + }) + } + } + } +} + +#[derive(Clone)] +pub struct NotificationItem { + /// Unique notification ID. + pub id: NotificationId, + /// Mark this notification as seen. + pub seen: bool, + /// Wrapped notification kind. + pub kind: NotificationKindItem, + /// Time the update has happened. + timestamp: Timestamp, +} + +impl NotificationItem { + pub fn id(&self) -> &NotificationId { + &self.id + } + + pub fn seen(&self) -> bool { + self.seen + } + + pub fn kind(&self) -> &NotificationKindItem { + &self.kind + } + + pub fn timestamp(&self) -> &Timestamp { + &self.timestamp + } +} + +impl TableItem<7> for NotificationItem { + fn row(&self, _theme: &Theme, highlight: bool) -> [Cell; 7] { + let seen = if self.seen { + label::blank() + } else { + label::positive(" ● ") + }; + + let (type_name, summary, status, id) = match &self.kind() { + NotificationKindItem::Branch { + name, + summary, + status, + id: _, + } => ("branch".to_string(), summary, status, 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, status, id.to_string()) + } + }; + + let timestamp = if highlight { + label::reversed(&format::timestamp(&self.timestamp)) + } else { + label::timestamp(&format::timestamp(&self.timestamp)) + }; + + [ + label::default(&format!(" {}", &self.id)).into(), + seen.into(), + label::alias(&type_name).into(), + label::default(summary).into(), + label::id(&id).into(), + label::default(status).into(), + timestamp.into(), + ] + } +} + +impl TryFrom<(&Repository, Notification)> for NotificationItem { + type Error = anyhow::Error; + + fn try_from(value: (&Repository, Notification)) -> Result { + let (repo, notification) = value; + let kind = NotificationKindItem::try_from((repo, notification.kind, notification.update))?; + + Ok(NotificationItem { + id: notification.id, + seen: notification.status.is_read(), + kind, + timestamp: notification.timestamp.into(), + }) + } +} + +impl PartialEq for NotificationItem { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + pub fn format_patch_state(state: &patch::State) -> (String, Color) { match state { patch::State::Open { conflicts: _ } => (" ● ".into(), Color::Green), diff --git a/src/ui/widget/label.rs b/src/ui/widget/label.rs index 7bdab245..06141aeb 100644 --- a/src/ui/widget/label.rs +++ b/src/ui/widget/label.rs @@ -8,6 +8,10 @@ use crate::ui::layout; use crate::ui::theme::style; use crate::ui::widget::{Widget, WidgetComponent}; +pub fn blank() -> Widget