Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
inbox: Add selection interface
Merged did:key:z6MkgFq6...nBGz opened 2 years ago
16 files changed +1268 -28 0e8f3495 78e18113
modified bin/commands.rs
@@ -1,5 +1,7 @@
#[path = "commands/help.rs"]
pub mod tui_help;
+
#[path = "commands/inbox.rs"]
+
pub mod tui_inbox;
#[path = "commands/issue.rs"]
pub mod tui_issue;
#[path = "commands/patch.rs"]
added bin/commands/inbox.rs
@@ -0,0 +1,152 @@
+
#[path = "inbox/select.rs"]
+
mod select;
+

+
use std::ffi::OsString;
+

+
use anyhow::anyhow;
+

+
use radicle_tui as tui;
+

+
use tui::cob::inbox::{self};
+
use tui::{context, log, Window};
+

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

+
pub const FPS: u64 = 60;
+
pub const HELP: Help = Help {
+
    name: "inbox",
+
    description: "Terminal interfaces for notifications",
+
    version: env!("CARGO_PKG_VERSION"),
+
    usage: r#"
+
Usage
+

+
    rad-tui inbox select [<option>...]
+

+
Other options
+

+
    --mode <MODE>           Set selection mode; see MODE below (default: operation)
+
    
+
    --sort-by <field>       Sort by `id` or `timestamp` (default: timestamp)
+
    --reverse, -r           Reverse the list
+
    --help                  Print help
+

+
    The MODE argument can be 'operation' or 'id'. 'operation' selects a notification id and
+
    an operation, whereas 'id' selects a notification id only.
+
"#,
+
};
+

+
pub struct Options {
+
    op: Operation,
+
}
+

+
pub enum Operation {
+
    Select { opts: SelectOptions },
+
}
+

+
#[derive(PartialEq, Eq)]
+
pub enum OperationName {
+
    Select,
+
}
+

+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
+
pub struct SelectOptions {
+
    mode: select::Mode,
+
    filter: inbox::Filter,
+
    sort_by: inbox::SortBy,
+
}
+

+
impl Args for Options {
+
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_args(args);
+
        let mut op: Option<OperationName> = None;
+
        let mut reverse = None;
+
        let mut field = None;
+
        let mut select_opts = SelectOptions::default();
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("help") | Short('h') => {
+
                    return Err(Error::Help.into());
+
                }
+

+
                // select options.
+
                Long("mode") | Short('m') if op == Some(OperationName::Select) => {
+
                    let val = parser.value()?;
+
                    let val = val.to_str().unwrap_or_default();
+

+
                    select_opts.mode = match val {
+
                        "operation" => select::Mode::Operation,
+
                        "id" => select::Mode::Id,
+
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
+
                    };
+
                }
+

+
                Long("reverse") | Short('r') => {
+
                    reverse = Some(true);
+
                }
+
                Long("sort-by") => {
+
                    let val = parser.value()?;
+

+
                    match terminal::args::string(&val).as_str() {
+
                        "timestamp" => field = Some("timestamp"),
+
                        "rowid" => field = Some("id"),
+
                        other => anyhow::bail!("unknown sorting field '{other}'"),
+
                    }
+
                }
+

+
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
+
                    "select" => op = Some(OperationName::Select),
+
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
+
                },
+
                _ => return Err(anyhow!(arg.unexpected())),
+
            }
+
        }
+

+
        select_opts.sort_by = if let Some(field) = field {
+
            inbox::SortBy {
+
                field,
+
                reverse: reverse.unwrap_or(false),
+
            }
+
        } else {
+
            inbox::SortBy::default()
+
        };
+

+
        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
+
            OperationName::Select => Operation::Select { opts: select_opts },
+
        };
+
        Ok((Options { op }, vec![]))
+
    }
+
}
+

+
pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
    let (_, id) = radicle::rad::cwd()
+
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+

+
    match options.op {
+
        Operation::Select { opts } => {
+
            let profile = terminal::profile()?;
+
            let context = context::Context::new(profile, id)?;
+

+
            log::enable(context.profile(), "inbox", "select")?;
+

+
            let mut app = select::App::new(
+
                context,
+
                opts.mode.clone(),
+
                opts.filter.clone(),
+
                opts.sort_by,
+
            );
+
            let output = Window::default().run(&mut app, 1000 / FPS)?;
+

+
            let output = output
+
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
+
                .unwrap_or_default();
+

+
            eprint!("{output}");
+
        }
+
    }
+

+
    Ok(())
+
}
added bin/commands/inbox/select.rs
@@ -0,0 +1,233 @@
+
#[path = "select/event.rs"]
+
mod event;
+
#[path = "select/page.rs"]
+
mod page;
+
#[path = "select/ui.rs"]
+
mod ui;
+

+
use std::fmt::Display;
+
use std::hash::Hash;
+

+
use anyhow::Result;
+
use serde::{Serialize, Serializer};
+

+
use tuirealm::application::PollStrategy;
+
use tuirealm::event::Key;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::cob::inbox::{Filter, SortBy};
+
use tui::context::Context;
+

+
use tui::ui::subscription;
+
use tui::ui::theme::Theme;
+
use tui::{Exit, PageStack, SelectionExit, Tui};
+

+
use page::ListView;
+

+
/// Wrapper around radicle's `PatchId` that serializes
+
/// to a human-readable string.
+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct PatchId(radicle::cob::patch::PatchId);
+

+
impl From<radicle::cob::patch::PatchId> for PatchId {
+
    fn from(value: radicle::cob::patch::PatchId) -> Self {
+
        PatchId(value)
+
    }
+
}
+

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

+
impl Serialize for PatchId {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        serializer.serialize_str(&format!("{}", *self.0))
+
    }
+
}
+

+
/// The application's subject. It tells the application
+
/// which widgets to render and which output to produce.
+
///
+
/// Depends on CLI arguments given by the user.
+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Mode {
+
    #[default]
+
    Operation,
+
    #[allow(dead_code)]
+
    Id,
+
}
+

+
/// The selected issue operation returned by the operation
+
/// selection widget.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub enum InboxOperation {
+
    Show,
+
    Clear,
+
}
+

+
impl Display for InboxOperation {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            InboxOperation::Show => {
+
                write!(f, "show")
+
            }
+
            InboxOperation::Clear => {
+
                write!(f, "clear")
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum ListCid {
+
    NotificationBrowser,
+
    Context,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    List(ListCid),
+
    #[default]
+
    GlobalListener,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    #[default]
+
    Tick,
+
    Quit(Option<SelectionExit>),
+
    Batch(Vec<Message>),
+
}
+

+
pub struct App {
+
    context: Context,
+
    pages: PageStack<Cid, Message>,
+
    theme: Theme,
+
    mode: Mode,
+
    filter: Filter,
+
    sort_by: SortBy,
+
    output: Option<SelectionExit>,
+
    quit: bool,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
impl App {
+
    pub fn new(context: Context, mode: Mode, filter: Filter, sort_by: SortBy) -> Self {
+
        Self {
+
            context,
+
            pages: PageStack::default(),
+
            theme: Theme::default(),
+
            mode,
+
            filter,
+
            sort_by,
+
            output: None,
+
            quit: false,
+
        }
+
    }
+

+
    fn view_list(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let home = Box::new(ListView::new(
+
            self.mode.clone(),
+
            self.filter.clone(),
+
            self.sort_by,
+
        ));
+
        self.pages.push(home, app, &self.context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn process(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        let theme = Theme::default();
+
        match message {
+
            Message::Batch(messages) => {
+
                let mut results = vec![];
+
                for message in messages {
+
                    if let Some(result) = self.process(app, message)? {
+
                        results.push(result);
+
                    }
+
                }
+
                match results.len() {
+
                    0 => Ok(None),
+
                    1 => Ok(Some(results[0].to_owned())),
+
                    _ => Ok(Some(Message::Batch(results))),
+
                }
+
            }
+
            Message::Quit(output) => {
+
                self.quit = true;
+
                self.output = output;
+
                Ok(None)
+
            }
+
            _ => self
+
                .pages
+
                .peek_mut()?
+
                .update(app, &self.context, &theme, message),
+
        }
+
    }
+
}
+

+
impl Tui<Cid, Message, SelectionExit> for App {
+
    fn init(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        self.view_list(app, &self.theme.clone())?;
+

+
        // Add global key listener and subscribe to key events
+
        let global = tui::ui::global_listener().to_boxed();
+
        app.mount(
+
            Cid::GlobalListener,
+
            global,
+
            vec![Sub::new(
+
                subscription::quit_clause(Key::Char('q')),
+
                SubClause::Always,
+
            )],
+
        )?;
+

+
        Ok(())
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        if let Ok(page) = self.pages.peek_mut() {
+
            page.view(app, frame);
+
        }
+
    }
+

+
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
+
        match app.tick(PollStrategy::Once) {
+
            Ok(messages) if !messages.is_empty() => {
+
                for message in messages {
+
                    let mut msg = Some(message);
+
                    while msg.is_some() {
+
                        msg = self.process(app, msg.unwrap())?;
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn exit(&self) -> Option<Exit<SelectionExit>> {
+
        if self.quit {
+
            return Some(Exit {
+
                value: self.output.clone(),
+
            });
+
        }
+
        None
+
    }
+
}
added bin/commands/inbox/select/event.rs
@@ -0,0 +1,161 @@
+
use radicle::node::notifications::NotificationId;
+

+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
+
use tuirealm::event::{Event, Key, KeyEvent};
+
use tuirealm::{MockComponent, NoUserEvent};
+

+
use radicle_tui as tui;
+

+
use tui::ui::state::ItemState;
+
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
+
use tui::ui::widget::context::{ContextBar, Shortcuts};
+
use tui::ui::widget::list::PropertyList;
+
use tui::ui::widget::Widget;
+
use tui::{Id, SelectionExit};
+

+
use super::ui::{IdSelect, OperationSelect};
+
use super::{InboxOperation, Message};
+

+
/// Since the framework does not know the type of messages that are being
+
/// passed around in the app, the following handlers need to be implemented for
+
/// each component used.
+
///
+
/// TODO: should handle `Event::WindowResize`, which is not emitted by `termion`.
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('q'),
+
                ..
+
            }) => Some(Message::Quit(None)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<NotificationId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => submit().map(|id| {
+
                let output = SelectionExit::default().with_id(Id::Notification(id));
+
                Message::Quit(Some(output))
+
            }),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<NotificationId> {
+
            match self.perform(Cmd::Submit) {
+
                CmdResult::Submit(state) => {
+
                    let selected = ItemState::try_from(state).ok()?.selected()?;
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => submit().map(|id| {
+
                let exit = SelectionExit::default()
+
                    .with_operation(InboxOperation::Show.to_string())
+
                    .with_id(Id::Notification(id));
+
                Message::Quit(Some(exit))
+
            }),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('c'),
+
                ..
+
            }) => submit().map(|id| {
+
                let exit = SelectionExit::default()
+
                    .with_operation(InboxOperation::Clear.to_string())
+
                    .with_id(Id::Notification(id));
+
                Message::Quit(Some(exit))
+
            }),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<LabeledContainer> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<PropertyList> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ContextBar> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Shortcuts> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
added bin/commands/inbox/select/page.rs
@@ -0,0 +1,168 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent};
+

+
use radicle_tui as tui;
+

+
use tui::cob::inbox::{Filter, SortBy};
+
use tui::context::Context;
+
use tui::ui::layout;
+
use tui::ui::state::ItemState;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::context::{Progress, Shortcuts};
+
use tui::ui::widget::Widget;
+
use tui::ViewPage;
+

+
use super::{ui, Application, Cid, ListCid, Message, Mode};
+

+
///
+
/// Home
+
///
+
pub struct ListView {
+
    active_component: ListCid,
+
    mode: Mode,
+
    filter: Filter,
+
    sort_by: SortBy,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
+
}
+

+
impl ListView {
+
    pub fn new(mode: Mode, filter: Filter, sort_by: SortBy) -> Self {
+
        Self {
+
            active_component: ListCid::NotificationBrowser,
+
            mode,
+
            filter,
+
            sort_by,
+
            shortcuts: HashMap::default(),
+
        }
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let state = app.state(&Cid::List(ListCid::NotificationBrowser))?;
+
        let progress = match ItemState::try_from(state) {
+
            Ok(state) => Progress::Step(
+
                state
+
                    .selected()
+
                    .map(|s| s.saturating_add(1))
+
                    .unwrap_or_default(),
+
                state.len(),
+
            ),
+
            Err(_) => Progress::None,
+
        };
+

+
        let context = ui::browse_context(context, theme, self.filter.clone(), progress);
+

+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::List(ListCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for ListView {
+
    fn mount(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let browser = ui::operation_select(theme, context, self.filter.clone(), self.sort_by, None)
+
            .to_boxed();
+
        self.shortcuts = browser.as_ref().shortcuts();
+

+
        match self.mode {
+
            Mode::Id => {
+
                let notif_browser =
+
                    ui::id_select(theme, context, self.filter.clone(), self.sort_by, None)
+
                        .to_boxed();
+
                self.shortcuts = notif_browser.as_ref().shortcuts();
+

+
                app.remount(Cid::List(ListCid::NotificationBrowser), browser, vec![])?;
+
            }
+
            Mode::Operation => {
+
                let notif_browser =
+
                    ui::operation_select(theme, context, self.filter.clone(), self.sort_by, None)
+
                        .to_boxed();
+
                self.shortcuts = notif_browser.as_ref().shortcuts();
+

+
                app.remount(Cid::List(ListCid::NotificationBrowser), browser, vec![])?;
+
            }
+
        };
+

+
        app.active(&Cid::List(self.active_component.clone()))?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.umount(&Cid::List(ListCid::NotificationBrowser))?;
+
        app.umount(&Cid::List(ListCid::Context))?;
+
        app.umount(&Cid::List(ListCid::Shortcuts))?;
+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        _message: Message,
+
    ) -> Result<Option<Message>> {
+
        self.update_context(app, context, theme)?;
+

+
        Ok(None)
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let context_h = app
+
            .query(&Cid::List(ListCid::Context), Attribute::Height)
+
            .unwrap_or_default()
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = 1u16;
+

+
        let layout = layout::default_page(area, context_h, shortcuts_h);
+

+
        app.view(
+
            &Cid::List(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+

+
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
+
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

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

+
    fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        Ok(())
+
    }
+
}
added bin/commands/inbox/select/ui.rs
@@ -0,0 +1,267 @@
+
use std::collections::HashMap;
+

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

+
use radicle_tui as tui;
+

+
use tui::cob::inbox::{Filter, SortBy};
+
use tui::context::Context;
+
use tui::ui::theme::{style, Theme};
+
use tui::ui::widget::context::{ContextBar, Progress, Shortcuts};
+
use tui::ui::widget::label::{self};
+
use tui::ui::widget::{Widget, WidgetComponent};
+

+
use super::ListCid;
+

+
pub struct NotificationBrowser {
+
    items: Vec<NotificationItem>,
+
    table: Widget<Table<NotificationItem, 7>>,
+
}
+

+
impl NotificationBrowser {
+
    pub fn new(
+
        theme: &Theme,
+
        context: &Context,
+
        sort_by: SortBy,
+
        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(18),
+
        ];
+

+
        let mut items = vec![];
+
        for notification in context.notifications() {
+
            if let Ok(item) =
+
                NotificationItem::try_from((context.repository(), notification.clone()))
+
            {
+
                items.push(item);
+
            }
+
        }
+

+
        match sort_by.field {
+
            "timestamp" => items.sort_by(|a, b| b.timestamp().cmp(a.timestamp())),
+
            "id" => items.sort_by(|a, b| b.id().cmp(a.id())),
+
            _ => {}
+
        }
+
        if sort_by.reverse {
+
            items.reverse();
+
        }
+

+
        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 {
+
        self.table.state()
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.table.perform(cmd)
+
    }
+
}
+

+
pub struct IdSelect {
+
    theme: Theme,
+
    browser: Widget<NotificationBrowser>,
+
}
+

+
impl IdSelect {
+
    pub fn new(theme: Theme, browser: Widget<NotificationBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

+
    pub fn items(&self) -> &Vec<NotificationItem> {
+
        self.browser.items()
+
    }
+

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::NotificationBrowser,
+
            tui::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::ui::shortcut(&self.theme, "enter", "select"),
+
                    tui::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

+
impl WidgetComponent for IdSelect {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        self.browser.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.browser.view(frame, area);
+
    }
+

+
    fn state(&self) -> State {
+
        self.browser.state()
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.browser.perform(cmd)
+
    }
+
}
+

+
pub struct OperationSelect {
+
    theme: Theme,
+
    browser: Widget<NotificationBrowser>,
+
}
+

+
impl OperationSelect {
+
    pub fn new(theme: Theme, browser: Widget<NotificationBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

+
    pub fn items(&self) -> &Vec<NotificationItem> {
+
        self.browser.items()
+
    }
+

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::NotificationBrowser,
+
            tui::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::ui::shortcut(&self.theme, "enter", "show"),
+
                    tui::ui::shortcut(&self.theme, "c", "clear"),
+
                    tui::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

+
impl WidgetComponent for OperationSelect {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        self.browser.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.browser.view(frame, area);
+
    }
+

+
    fn state(&self) -> State {
+
        self.browser.state()
+
    }
+

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.browser.perform(cmd)
+
    }
+
}
+

+
pub fn id_select(
+
    theme: &Theme,
+
    context: &Context,
+
    _filter: Filter,
+
    sort_by: SortBy,
+
    selected: Option<Notification>,
+
) -> Widget<IdSelect> {
+
    let browser = Widget::new(NotificationBrowser::new(theme, context, sort_by, selected));
+

+
    Widget::new(IdSelect::new(theme.clone(), browser))
+
}
+

+
pub fn operation_select(
+
    theme: &Theme,
+
    context: &Context,
+
    _filter: Filter,
+
    sort_by: SortBy,
+
    selected: Option<Notification>,
+
) -> Widget<OperationSelect> {
+
    let browser = Widget::new(NotificationBrowser::new(theme, context, sort_by, selected));
+

+
    Widget::new(OperationSelect::new(theme.clone(), browser))
+
}
+

+
pub fn browse_context(
+
    _context: &Context,
+
    _theme: &Theme,
+
    _filter: Filter,
+
    progress: Progress,
+
) -> Widget<ContextBar> {
+
    let context = label::reversable("/").style(style::magenta_reversed());
+
    let filter = label::default("").style(style::magenta_dim());
+

+
    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
+

+
    let spacer = label::default("");
+
    let _divider = label::default(" | ");
+

+
    let context_bar = ContextBar::new(
+
        label::group(&[context]),
+
        label::group(&[filter]),
+
        label::group(&[spacer.clone()]),
+
        label::group(&[
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
            spacer.clone(),
+
        ]),
+
        label::group(&[progress]),
+
    );
+

+
    Widget::new(context_bar).height(1)
+
}
modified bin/commands/issue/select/event.rs
@@ -1,17 +1,15 @@
-
use radicle::issue::IssueId;
-
use tui::ui::state::ItemState;
-
use tui::SelectionExit;
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent};
use tuirealm::{MockComponent, NoUserEvent};

use radicle_tui as tui;

+
use tui::ui::state::ItemState;
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
use tui::ui::widget::context::{ContextBar, Shortcuts};
use tui::ui::widget::list::PropertyList;
-

use tui::ui::widget::Widget;
+
use tui::{Id, SelectionExit};

use super::ui::{IdSelect, OperationSelect};
use super::{IssueOperation, Message};
@@ -68,7 +66,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
            Event::Keyboard(KeyEvent {
                code: Key::Enter, ..
            }) => submit().map(|id| {
-
                let output = SelectionExit::default().with_id(IssueId::from(id));
+
                let output = SelectionExit::default().with_id(Id::Object(id));
                Message::Quit(Some(output))
            }),
            _ => None,
@@ -113,7 +111,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(IssueOperation::Show.to_string())
-
                    .with_id(IssueId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -122,7 +120,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(IssueOperation::Delete.to_string())
-
                    .with_id(IssueId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -131,7 +129,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(IssueOperation::Edit.to_string())
-
                    .with_id(IssueId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -140,7 +138,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(IssueOperation::Comment.to_string())
-
                    .with_id(IssueId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            _ => None,
modified bin/commands/patch/select/event.rs
@@ -1,17 +1,15 @@
-
use radicle::patch::PatchId;
-
use tui::ui::state::ItemState;
-
use tui::SelectionExit;
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent};
use tuirealm::{MockComponent, NoUserEvent};

use radicle_tui as tui;

+
use tui::ui::state::ItemState;
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
use tui::ui::widget::context::{ContextBar, Shortcuts};
use tui::ui::widget::list::PropertyList;
-

use tui::ui::widget::Widget;
+
use tui::{Id, SelectionExit};

use super::ui::{IdSelect, OperationSelect};
use super::{Message, PatchOperation};
@@ -68,7 +66,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
            Event::Keyboard(KeyEvent {
                code: Key::Enter, ..
            }) => submit().map(|id| {
-
                let output = SelectionExit::default().with_id(PatchId::from(id));
+
                let output = SelectionExit::default().with_id(Id::Object(id));
                Message::Quit(Some(output))
            }),
            _ => None,
@@ -113,7 +111,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Show.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -122,7 +120,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Checkout.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -131,7 +129,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Delete.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -140,7 +138,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Edit.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            Event::Keyboard(KeyEvent {
@@ -149,7 +147,7 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
            }) => submit().map(|id| {
                let exit = SelectionExit::default()
                    .with_operation(PatchOperation::Comment.to_string())
-
                    .with_id(PatchId::from(id));
+
                    .with_id(Id::Object(id));
                Message::Quit(Some(exit))
            }),
            _ => None,
modified bin/main.rs
@@ -111,6 +111,13 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
+
        "inbox" => {
+
            terminal::run_command_args::<tui_inbox::Options, _>(
+
                tui_inbox::HELP,
+
                tui_inbox::run,
+
                args.to_vec(),
+
            );
+
        }
        other => Err(Some(anyhow!(
            "`{other}` is not a command. See `rad-tui --help` for a list of commands.",
        ))),
modified bin/terminal/args.rs
@@ -93,6 +93,10 @@ pub fn finish(unparsed: Vec<OsString>) -> anyhow::Result<()> {
    Ok(())
}

+
pub fn string(val: &OsString) -> String {
+
    val.to_string_lossy().to_string()
+
}
+

#[allow(dead_code)]
pub fn rev(val: &OsString) -> anyhow::Result<Rev> {
    let s = val.to_str().ok_or(anyhow!("invalid git rev {val:?}"))?;
modified src/cob.rs
@@ -6,6 +6,7 @@ use radicle::cob::Label;
use radicle::prelude::Did;

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

added src/cob/inbox.rs
@@ -0,0 +1,38 @@
+
use anyhow::Result;
+

+
use radicle::node::notifications::Notification;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct Filter {}
+

+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
pub struct SortBy {
+
    pub reverse: bool,
+
    pub field: &'static str,
+
}
+

+
impl Default for SortBy {
+
    fn default() -> Self {
+
        Self {
+
            reverse: true,
+
            field: "timestamp",
+
        }
+
    }
+
}
+

+
pub fn all(repository: &Repository, profile: &Profile) -> Result<Vec<Notification>> {
+
    let all = profile
+
        .notifications_mut()?
+
        .by_repo(&repository.id, "timestamp")?
+
        .collect::<Vec<_>>();
+

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

+
    Ok(notifications)
+
}
modified src/context.rs
@@ -5,6 +5,7 @@ use radicle::cob::patch::{Patch, PatchId};
use radicle::crypto::ssh::keystore::{Keystore, MemorySigner};
use radicle::crypto::Signer;
use radicle::identity::{Project, RepoId};
+
use radicle::node::notifications::Notification;
use radicle::profile::env::RAD_PASSPHRASE;
use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage};
@@ -15,6 +16,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 +43,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 +51,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 +64,7 @@ impl Context {
            repository,
            issues,
            patches,
+
            notifications,
            signer,
        })
    }
@@ -103,6 +110,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/lib.rs
@@ -1,7 +1,9 @@
+
use std::fmt::Display;
use std::hash::Hash;
use std::time::Duration;

use anyhow::Result;
+
use radicle::node::notifications::NotificationId;
use serde::ser::{Serialize, SerializeStruct, Serializer};

use radicle::cob::ObjectId;
@@ -49,11 +51,31 @@ pub struct Exit<T> {
    pub value: Option<T>,
}

+
/// Returned ids can be of type `ObjectId` or `NotificationId`.
+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum Id {
+
    Object(ObjectId),
+
    Notification(NotificationId),
+
}
+

+
impl Display for Id {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Id::Object(id) => {
+
                write!(f, "{id}")
+
            }
+
            Id::Notification(id) => {
+
                write!(f, "{id}")
+
            }
+
        }
+
    }
+
}
+

/// The output that is returned by all selection interfaces.
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub struct SelectionExit {
    operation: Option<String>,
-
    ids: Vec<ObjectId>,
+
    ids: Vec<Id>,
    args: Vec<String>,
}

@@ -63,7 +85,7 @@ impl SelectionExit {
        self
    }

-
    pub fn with_id(mut self, id: ObjectId) -> Self {
+
    pub fn with_id(mut self, id: Id) -> Self {
        self.ids.push(id);
        self
    }
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;