Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
inbox: Show notifications
Erik Kundt committed 2 years ago
commit f23cc04759a543247c3b48d426291a1d30d5c19b
parent e0ddf6906ac6ab3a7918b62b6069d1b6cc5b098d
8 files changed +290 -23
modified bin/commands/inbox/select.rs
@@ -60,6 +60,7 @@ impl Serialize for PatchId {
pub enum Mode {
    #[default]
    Operation,
+
    #[allow(dead_code)]
    Id,
}

modified bin/commands/inbox/select/event.rs
@@ -36,7 +36,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => None,
+
                CmdResult::Submit(_) => None,
                _ => None,
            }
        };
modified 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<Cid, Message> 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<Cid, Message> for ListView {
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
    }

-
    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
    fn subscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
        Ok(())
    }

-
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
    fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
        Ok(())
    }
}
modified 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<NotificationItem>,
+
    table: Widget<Table<NotificationItem, 7>>,
+
}
+

+
impl NotificationBrowser {
+
    pub fn new(theme: &Theme, context: &Context, selected: Option<Notification>) -> 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<NotificationItem> {
+
        &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<Notification>,
) -> Widget<OperationSelect> {
-
    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<ContextBar> {
    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]),
modified 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<Vec<Notification>> {
+
    let all = profile
+
        .notifications_mut()?
+
        .by_repo(&repository.id, "timestamp")?
+
        .collect::<Vec<_>>();
+

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

+
    Ok(notifications)
+
}
modified 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<Vec<(IssueId, Issue)>>,
    patches: Option<Vec<(PatchId, Patch)>>,
+
    notifications: Vec<Notification>,
    signer: Option<Box<dyn Signer>>,
}

@@ -47,6 +52,8 @@ 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;
@@ -58,6 +65,7 @@ impl Context {
            repository,
            issues,
            patches,
+
            notifications,
            signer,
        })
    }
@@ -103,6 +111,10 @@ impl Context {
        &self.patches
    }

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

    #[allow(clippy::borrowed_box)]
    pub fn signer(&self) -> &Option<Box<dyn Signer>> {
        &self.signer
modified 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::cob::issue::{self, Issue, IssueId};
+
use radicle::cob::patch::{self, Patch, PatchId};
+
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};
-
use radicle::Profile;
-

-
use radicle::cob::issue::{self, Issue, IssueId};
-
use radicle::cob::patch::{self, Patch, PatchId};
-
use radicle::cob::{Label, Timestamp};
+
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<ObjectId>,
+
    },
+
    Cob {
+
        type_name: String,
+
        summary: String,
+
        status: String,
+
        id: Option<ObjectId>,
+
    },
+
}
+

+
impl TryFrom<(&Repository, NotificationKind, RefUpdate)> for NotificationKindItem {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Repository, NotificationKind, RefUpdate)) -> Result<Self, Self::Error> {
+
        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<Self, Self::Error> {
+
        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),
modified 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<Label> {
+
    default("")
+
}
+

pub fn default(content: &str) -> Widget<Label> {
    // TODO: Remove when size constraints are implemented
    let width = content.chars().count() as u16;