diff --git a/bin/cob.rs b/bin/cob.rs index b20f2314..a5dfc54b 100644 --- a/bin/cob.rs +++ b/bin/cob.rs @@ -1,3 +1,2 @@ -pub mod inbox; pub mod issue; pub mod patch; diff --git a/bin/cob/inbox.rs b/bin/cob/inbox.rs deleted file mode 100644 index 1146ff43..00000000 --- a/bin/cob/inbox.rs +++ /dev/null @@ -1,38 +0,0 @@ -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> { - 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/bin/commands/inbox.rs b/bin/commands/inbox.rs index 5c081f45..1214711c 100644 --- a/bin/commands/inbox.rs +++ b/bin/commands/inbox.rs @@ -7,12 +7,14 @@ use std::ffi::OsString; use anyhow::anyhow; +use radicle::storage::{HasRepoId, ReadRepository}; + use radicle_cli::terminal; use radicle_cli::terminal::{Args, Error, Help}; use self::common::{Mode, RepositoryMode, SelectionMode}; -use crate::cob::inbox; +use crate::ui::items::notification::filter::{NotificationFilter, SortBy}; pub const HELP: Help = Help { name: "inbox", @@ -59,11 +61,11 @@ pub enum OperationName { Unknown, } -#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct ListOptions { mode: Mode, - filter: inbox::Filter, - sort_by: inbox::SortBy, + filter: NotificationFilter, + sort_by: SortBy, json: bool, } @@ -166,12 +168,12 @@ impl Args for Options { .mode .with_repository(repository_mode.unwrap_or_default()); list_opts.sort_by = if let Some(field) = field { - inbox::SortBy { + SortBy { field, reverse: reverse.unwrap_or(false), } } else { - inbox::SortBy::default() + SortBy::default() }; // Map local commands. Forward help and ignore `no-forward`. @@ -206,12 +208,13 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul let context = list::Context { profile, - repository, + project: repository.identity_doc()?.project()?, + rid: repository.rid(), mode: opts.mode, filter: opts.filter.clone(), sort_by: opts.sort_by, }; - let selection = list::App::new(context).run().await?; + let selection = list::App::default().run(context).await?; if opts.json { let selection = selection diff --git a/bin/commands/inbox/list.rs b/bin/commands/inbox/list.rs index b7da5410..42bc27d1 100644 --- a/bin/commands/inbox/list.rs +++ b/bin/commands/inbox/list.rs @@ -2,205 +2,75 @@ mod ui; use std::str::FromStr; - -use anyhow::Result; +use std::sync::Arc; +use std::sync::Mutex; use ratatui::Viewport; -use termion::event::Key; - -use ratatui::layout::Constraint; -use ratatui::style::Stylize; -use ratatui::text::Text; use radicle::identity::Project; use radicle::node::notifications::NotificationId; -use radicle::storage::git::Repository; -use radicle::storage::ReadRepository; +use radicle::prelude::RepoId; use radicle::storage::ReadStorage; use radicle::Profile; use radicle_tui as tui; use tui::store; -use tui::ui::rm::widget::container::{Container, Footer, FooterProps, Header, HeaderProps}; -use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState}; -use tui::ui::rm::widget::window::{ - Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps, -}; -use tui::ui::rm::widget::{ToWidget, Widget}; -use tui::ui::span; +use tui::task::{Process, Task}; +use tui::ui::rm::widget::text::TextViewState; +use tui::ui::rm::widget::window::{Window, WindowProps}; +use tui::ui::rm::widget::ToWidget; use tui::ui::BufferedValue; -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::filter::Filter; +use crate::ui::items::notification::filter::NotificationFilter; +use crate::ui::items::notification::filter::SortBy; +use crate::ui::items::notification::Notification; -use self::ui::Browser; -use self::ui::BrowserProps; - -use super::common::SelectionMode; use super::common::{Mode, RepositoryMode}; type Selection = tui::Selection; -#[allow(dead_code)] +#[derive(Clone, Debug)] pub struct Context { pub profile: Profile, - pub repository: Repository, + pub project: Project, + pub rid: RepoId, pub mode: Mode, - pub filter: inbox::Filter, - pub sort_by: inbox::SortBy, + pub filter: NotificationFilter, + pub sort_by: SortBy, } -pub struct App { - context: Context, -} +#[derive(Default)] +pub struct App {} -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub enum AppPage { - Browse, - Help, -} +impl App { + pub async fn run(&self, context: Context) -> anyhow::Result> { + let channel = Channel::default(); + let state = State::new(context.clone())?; + let tx = channel.tx.clone(); -#[derive(Clone, Debug)] -pub struct BrowserState { - items: Vec, - selected: Option, - filter: NotificationItemFilter, - search: BufferedValue, - show_search: bool, -} + let window = Window::default() + .page(PageState::Browse, ui::browser_page(&state, &channel)) + .page(PageState::Help, ui::help_page(&state, &channel)) + .to_widget(tx.clone()) + .on_init(|| Some(Message::Reload)) + .on_update(|state: &State| { + WindowProps::default() + .current_page(state.pages.peek().unwrap_or(&PageState::Browse).clone()) + .to_boxed_any() + .into() + }); -impl BrowserState { - pub fn notifications(&self) -> Vec { - self.items - .iter() - .filter(|patch| self.filter.matches(patch)) - .cloned() - .collect() - } -} - -#[derive(Clone, Debug)] -pub struct HelpState { - text: TextViewState, -} - -#[derive(Clone, Debug)] -pub struct State { - mode: Mode, - project: Project, - pages: PageStack, - browser: BrowserState, - help: HelpState, -} - -impl TryFrom<&Context> for State { - type Error = anyhow::Error; - - fn try_from(context: &Context) -> Result { - let doc = context.repository.identity_doc()?; - let project = doc.project()?; - - let search = BufferedValue::new(String::new()); - let filter = NotificationItemFilter::from_str(&search.read()).unwrap_or_default(); - - let mut notifications = match &context.mode.repository() { - RepositoryMode::All => { - let mut repos = context.profile.storage.repositories()?; - repos.sort_by_key(|r| r.rid); - - let mut notifs = vec![]; - for repo in repos { - let repo = context.profile.storage.repository(repo.rid)?; - - let items = inbox::all(&repo, &context.profile)? - .iter() - .map(|notif| NotificationItem::new(&context.profile, &repo, notif)) - .filter_map(|item| item.ok()) - .flatten() - .collect::>(); - - notifs.extend(items); - } - - notifs - } - RepositoryMode::Contextual => { - let notifs = inbox::all(&context.repository, &context.profile)?; - - notifs - .iter() - .map(|notif| { - NotificationItem::new(&context.profile, &context.repository, notif) - }) - .filter_map(|item| item.ok()) - .flatten() - .collect::>() - } - RepositoryMode::ByRepo((rid, _)) => { - let repo = context.profile.storage.repository(*rid)?; - let notifs = inbox::all(&repo, &context.profile)?; - - notifs - .iter() - .map(|notif| NotificationItem::new(&context.profile, &repo, notif)) - .filter_map(|item| item.ok()) - .flatten() - .collect::>() - } - }; - - // Set project name - let mode = match &context.mode.repository() { - RepositoryMode::ByRepo((rid, _)) => { - let project = context - .profile - .storage - .repository(*rid)? - .identity_doc()? - .project()?; - let name = project.name().to_string(); - - context - .mode - .clone() - .with_repository(RepositoryMode::ByRepo((*rid, Some(name)))) - } - _ => context.mode.clone(), - }; - - // Apply sorting - match context.sort_by.field { - "timestamp" => notifications.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)), - "id" => notifications.sort_by(|a, b| a.id.cmp(&b.id)), - _ => {} - } - if context.sort_by.reverse { - notifications.reverse(); - } - - // Sort by project if all notifications are shown - if let RepositoryMode::All = mode.repository() { - notifications.sort_by(|a, b| a.project.cmp(&b.project)); - } - - Ok(Self { - mode: context.mode.clone(), - project, - pages: PageStack::new(vec![AppPage::Browse]), - browser: BrowserState { - items: notifications, - selected: Some(0), - filter, - search, - show_search: false, - }, - help: HelpState { - text: TextViewState::default().content(help_text()), - }, - }) + tui::rm( + state, + window, + Viewport::Inline(20), + channel, + vec![Loader::new(context)], + ) + .await } } @@ -215,6 +85,109 @@ pub enum Message { OpenHelp, LeavePage, ScrollHelp { state: TextViewState }, + Reload, + NotificationsLoaded(Vec), +} + +#[derive(Clone, Debug)] +pub struct BrowserState { + items: Arc>>, + selected: Option, + filter: NotificationFilter, + search: BufferedValue, + show_search: bool, + is_loading: bool, +} + +impl BrowserState { + fn items(&self) -> Vec { + let items = self.items.lock().unwrap(); + items + .iter() + .filter(|n| self.filter.matches(n)) + .cloned() + .collect() + } + + fn apply_filter(&mut self, filter: NotificationFilter) { + self.filter = filter; + } + + fn apply_notifications(&mut self, notifications: Vec) { + let mut items = self.items.lock().unwrap(); + *items = notifications; + } + + fn apply_sorting(&mut self, context: &Context) { + let mut items = self.items.lock().unwrap(); + // Apply sorting + match context.sort_by.field { + "timestamp" => items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)), + "id" => items.sort_by(|a, b| a.id.cmp(&b.id)), + _ => {} + } + if context.sort_by.reverse { + items.reverse(); + } + + // Set project name + let mode = match context.mode.repository() { + RepositoryMode::ByRepo((rid, _)) => { + let name = context.project.name().to_string(); + context + .mode + .clone() + .with_repository(RepositoryMode::ByRepo((*rid, Some(name)))) + } + _ => context.mode.clone(), + }; + + // Sort by project if all notifications are shown + if let RepositoryMode::All = mode.repository() { + items.sort_by(|a, b| a.project.cmp(&b.project)); + } + } +} + +#[derive(Clone, Debug)] +pub struct HelpState { + text: TextViewState, +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub enum PageState { + Browse, + Help, +} + +#[derive(Clone, Debug)] +pub struct State { + pages: PageStack, + browser: BrowserState, + help: HelpState, + context: Arc>, +} + +impl State { + fn new(context: Context) -> Result { + let search = BufferedValue::new(context.filter.to_string()); + + Ok(Self { + pages: PageStack::new(vec![PageState::Browse]), + browser: BrowserState { + items: Arc::new(Mutex::new(vec![])), + selected: Some(0), + filter: context.filter.clone(), + search, + show_search: false, + is_loading: false, + }, + help: HelpState { + text: TextViewState::default().content(ui::help_text()), + }, + context: Arc::new(Mutex::new(context)), + }) + } } impl store::Update for State { @@ -233,11 +206,14 @@ impl store::Update 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.apply_filter( + NotificationFilter::from_str(&self.browser.search.read()) + .unwrap_or(NotificationFilter::Invalid), + ); + let items = self.browser.items.lock().unwrap(); if let Some(selected) = self.browser.selected { - if selected > self.browser.notifications().len() { + if selected > items.len() { self.browser.selected = Some(0); } } @@ -252,13 +228,15 @@ impl store::Update 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.apply_filter( + NotificationFilter::from_str(&self.browser.search.read()) + .unwrap_or(NotificationFilter::Invalid), + ); None } Message::OpenHelp => { - self.pages.push(AppPage::Help); + self.pages.push(PageState::Help); None } Message::LeavePage => { @@ -269,180 +247,122 @@ impl store::Update for State { self.help.text = state; None } + Message::Reload => { + self.browser.is_loading = true; + None + } + Message::NotificationsLoaded(notifications) => { + let context = self.context.lock().unwrap(); + self.browser.apply_notifications(notifications); + self.browser.apply_filter(self.browser.filter.clone()); + self.browser.apply_sorting(&context); + self.browser.is_loading = false; + None + } } } } -impl App { - pub fn new(context: Context) -> Self { +#[derive(Clone, Debug)] +pub struct Loader { + context: Context, +} + +impl Loader { + fn new(context: Context) -> Self { Self { context } } +} - pub async fn run(&self) -> Result> { - let channel = Channel::default(); - let state = State::try_from(&self.context)?; - let tx = channel.tx.clone(); +#[derive(Debug)] +pub struct NotificationLoader { + context: Context, +} - let window = Window::default() - .page(AppPage::Browse, browser_page(&state, &channel)) - .page(AppPage::Help, help_page(&state, &channel)) - .to_widget(tx.clone()) - .on_update(|state: &State| { - WindowProps::default() - .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone()) - .to_boxed_any() - .into() - }); - - tui::rm(state, window, Viewport::Inline(20), channel).await +impl NotificationLoader { + fn new(context: Context) -> Self { + NotificationLoader { context } } } -fn browser_page(_state: &State, channel: &Channel) -> Widget { - let tx = channel.tx.clone(); +impl Task for NotificationLoader { + type Return = Message; - let content = Browser::new(tx.clone()) - .to_widget(tx.clone()) - .on_update(|state| BrowserProps::from(state).to_boxed_any().into()); + fn run(&self) -> anyhow::Result> { + let notifications = match self.context.mode.repository() { + RepositoryMode::All => { + let notifs = self.context.profile.notifications_mut()?; + let all = notifs.all()?; - let shortcuts = Shortcuts::default() - .to_widget(tx.clone()) - .on_update(|state: &State| { - let shortcuts = if state.browser.show_search { - vec![("esc", "cancel"), ("enter", "apply")] - } else { - match state.mode.selection() { - SelectionMode::Id => vec![("enter", "select"), ("/", "search")], - SelectionMode::Operation => vec![ - ("enter", "show"), - ("c", "clear"), - ("/", "search"), - ("?", "help"), - ], - } - }; - - ShortcutsProps::default() - .shortcuts(&shortcuts) - .to_boxed_any() - .into() - }); - - Page::default() - .content(content) - .shortcuts(shortcuts) - .to_widget(tx.clone()) - .on_event(|key, _, props| { - let default = PageProps::default(); - let props = props - .and_then(|props| props.inner_ref::()) - .unwrap_or(&default); - - if props.handle_keys { - match key { - Key::Char('q') | Key::Ctrl('c') => Some(Message::Exit { selection: None }), - Key::Char('?') => Some(Message::OpenHelp), - _ => None, - } - } else { - None - } - }) - .on_update(|state: &State| { - PageProps::default() - .handle_keys(!state.browser.show_search) - .to_boxed_any() - .into() - }) -} - -fn help_page(_state: &State, channel: &Channel) -> Widget { - let tx = channel.tx.clone(); - - let content = Container::default() - .header(Header::default().to_widget(tx.clone()).on_update(|_| { - HeaderProps::default() - .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec()) - .to_boxed_any() - .into() - })) - .content( - TextView::default() - .to_widget(tx.clone()) - .on_event(|_, vs, _| { - vs.and_then(|vs| vs.unwrap_textview()) - .map(|tvs| Message::ScrollHelp { state: tvs }) - }) - .on_update(|state: &State| { - TextViewProps::default() - .state(Some(state.help.text.clone())) - .to_boxed_any() - .into() - }), - ) - .footer( - Footer::default() - .to_widget(tx.clone()) - .on_update(|state: &State| { - FooterProps::default() - .columns( - [ - Column::new(Text::raw(""), Constraint::Fill(1)), - Column::new( - span::default(&format!("{}%", state.help.text.scroll)).dim(), - Constraint::Min(4), - ), - ] - .to_vec(), + all.filter_map(|notif| notif.ok()) + .map(|notif| { + let repo = self.context.profile.storage.repository(notif.repo)?; + Notification::new( + &self.context.profile, + &self.context.project, + &repo, + ¬if, ) - .to_boxed_any() - .into() - }), - ) - .to_widget(tx.clone()); + }) + .filter_map(|notif| notif.ok()) + .flatten() + .collect::>() + } + RepositoryMode::Contextual => { + let repo = self.context.profile.storage.repository(self.context.rid)?; + let notifs = self.context.profile.notifications_mut()?; + let by_repo = notifs.by_repo(&repo.id, "timestamp")?; - let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| { - ShortcutsProps::default() - .shortcuts(&[("?", "close")]) - .to_boxed_any() - .into() - }); + by_repo + .filter_map(|notif| notif.ok()) + .map(|notif| { + let repo = self.context.profile.storage.repository(notif.repo)?; + Notification::new( + &self.context.profile, + &self.context.project, + &repo, + ¬if, + ) + }) + .filter_map(|notif| notif.ok()) + .flatten() + .collect::>() + } + RepositoryMode::ByRepo((rid, _)) => { + let repo = self.context.profile.storage.repository(*rid)?; + let notifs = self.context.profile.notifications_mut()?; + let by_repo = notifs.by_repo(&repo.id, "timestamp")?; - Page::default() - .content(content) - .shortcuts(shortcuts) - .to_widget(tx.clone()) - .on_event(|key, _, _| match key { - Key::Char('q') | Key::Ctrl('c') => Some(Message::Exit { selection: None }), - Key::Char('?') => Some(Message::LeavePage), - _ => None, - }) - .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into()) + by_repo + .filter_map(|notif| notif.ok()) + .map(|notif| { + let repo = self.context.profile.storage.repository(notif.repo)?; + Notification::new( + &self.context.profile, + &self.context.project, + &repo, + ¬if, + ) + }) + .filter_map(|notif| notif.ok()) + .flatten() + .collect::>() + } + }; + + Ok(vec![Message::NotificationsLoaded(notifications)]) + } } -fn help_text() -> String { - r#"# Generic keybindings - -`↑,k`: move cursor one line up -`↓,j: move cursor one line down -`PageUp`: move cursor one page up -`PageDown`: move cursor one page down -`Home`: move cursor to the first line -`End`: move cursor to the last line -`Esc`: Cancel -`q`: Quit - -# Specific keybindings - -`enter`: Select notification (if --mode id) -`enter`: Show notification -`c`: Clear notifications -`/`: Search -`?`: Show help - -# Searching - -Pattern: is: | is:patch | is:issue | -Example: is:unseen is:patch Print"# - .into() +impl Process for Loader { + async fn process(&mut self, message: Message) -> anyhow::Result> { + match message { + Message::Reload => { + let loader = NotificationLoader::new(self.context.clone()); + let messages = tokio::spawn(async move { loader.run() }).await.unwrap()?; + Ok(messages) + } + _ => Ok(vec![]), + } + } } diff --git a/bin/commands/inbox/list/ui.rs b/bin/commands/inbox/list/ui.rs index b53864a7..4b71917d 100644 --- a/bin/commands/inbox/list/ui.rs +++ b/bin/commands/inbox/list/ui.rs @@ -1,35 +1,40 @@ use std::collections::HashMap; use std::str::FromStr; -use ratatui::Frame; -use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::broadcast; use termion::event::Key; use ratatui::layout::{Constraint, Layout}; use ratatui::style::Stylize; use ratatui::text::{Line, Text}; +use ratatui::widgets::Clear; +use ratatui::Frame; use radicle_tui as tui; +use tui::ui::rm::widget; use tui::ui::rm::widget::container::{ Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, }; -use tui::ui::rm::widget::input::{TextField, TextFieldProps}; use tui::ui::rm::widget::list::{Table, TableProps}; -use tui::ui::rm::widget::{self, ViewProps}; -use tui::ui::rm::widget::{RenderProps, ToWidget, View}; +use tui::ui::rm::widget::text::{ + Label, LabelProps, TextField, TextFieldProps, TextView, TextViewProps, +}; +use tui::ui::rm::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps}; +use tui::ui::rm::widget::{RenderProps, ToWidget, View, ViewProps}; use tui::ui::span; +use tui::ui::theme; use tui::ui::Column; - -use tui::{BoxedAny, Selection}; +use tui::{BoxedAny, Channel, Selection}; use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode}; -use crate::ui::items::{NotificationItem, NotificationItemFilter, NotificationState}; +use crate::ui::items::notification::filter::NotificationFilter; +use crate::ui::items::notification::{Notification, NotificationState}; use super::{Message, State}; -type Widget = widget::Widget; +type AppWidget = widget::Widget; #[derive(Clone, Default)] pub struct BrowserProps<'a> { @@ -38,7 +43,7 @@ pub struct BrowserProps<'a> { /// Table title header: String, /// Filtered notifications. - notifications: Vec, + notifications: Vec, /// Current (selected) table index selected: Option, /// Notification statistics. @@ -49,17 +54,21 @@ pub struct BrowserProps<'a> { show_search: bool, /// Current search string. search: String, + /// If loading widget should be shown + show_loading: bool, } impl From<&State> for BrowserProps<'_> { fn from(state: &State) -> Self { - let header = match state.mode.repository() { - RepositoryMode::Contextual => state.project.name().to_string(), + let context = state.context.lock().unwrap(); + + let header = match context.mode.repository() { + RepositoryMode::Contextual => context.project.name().to_string(), RepositoryMode::All => "All repositories".to_string(), RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(), }; - let notifications = state.browser.notifications(); + let notifications = state.browser.items.lock().unwrap().to_vec(); // Compute statistics let mut seen = 0; @@ -74,9 +83,9 @@ impl From<&State> for BrowserProps<'_> { let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]); Self { - mode: state.mode.clone(), + mode: context.mode.clone(), header, - notifications, + notifications: notifications, selected: state.browser.selected, stats, columns: [ @@ -84,11 +93,11 @@ impl From<&State> for BrowserProps<'_> { Column::new("", Constraint::Length(3)), Column::new("", Constraint::Fill(5)), Column::new("", Constraint::Fill(1)) - .skip(*state.mode.repository() != RepositoryMode::All), + .skip(*context.mode.repository() != RepositoryMode::All), Column::new("", Constraint::Fill(1)) .hide_small() .hide_medium(), - Column::new("", Constraint::Length(8)), + Column::new("", Constraint::Length(20)), Column::new("", Constraint::Length(10)), Column::new("", Constraint::Min(12)).hide_small(), Column::new("", Constraint::Min(14)).hide_small(), @@ -96,19 +105,22 @@ impl From<&State> for BrowserProps<'_> { .to_vec(), search: state.browser.search.read(), show_search: state.browser.show_search, + show_loading: state.browser.is_loading, } } } pub struct Browser { /// Notification widget - notifications: Widget, + notifications: AppWidget, + /// Loader widget + loader: AppWidget, /// Search widget - search: Widget, + search: AppWidget, } impl Browser { - pub fn new(tx: UnboundedSender) -> Self { + pub fn new(tx: broadcast::Sender) -> Self { Self { notifications: Container::default() .header(Header::default().to_widget(tx.clone()).on_update(|state| { @@ -126,7 +138,7 @@ impl Browser { .into() })) .content( - Table::::default() + Table::::default() .to_widget(tx.clone()) .on_event(|_, s, _| { let (selected, _) = @@ -137,10 +149,11 @@ impl Browser { }) .on_update(|state| { let props = BrowserProps::from(state); + let items = state.browser.items(); TableProps::default() .columns(props.columns) - .items(state.browser.notifications()) + .items(items) .selected(state.browser.selected) .to_boxed_any() .into() @@ -161,6 +174,15 @@ impl Browser { .to_boxed_any() .into() }), + loader: Container::default() + .content(Label::default().to_widget(tx.clone()).on_update(|_| { + LabelProps::default() + .text(" Loading ") + .style(theme::style::yellow().slow_blink()) + .to_boxed_any() + .into() + })) + .to_widget(tx.clone()), search: TextField::default() .to_widget(tx.clone()) .on_event(|_, s, _| { @@ -240,6 +262,7 @@ impl View for Browser { fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) { self.notifications.update(state); + self.loader.update(state); self.search.update(state); } @@ -249,6 +272,17 @@ impl View for Browser { .and_then(|props| props.inner_ref::()) .unwrap_or(&default); + let loader_area = |table_area| { + let [table_area, _] = + Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).areas(table_area); + let [_, loader_area] = + Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).areas(table_area); + let [_, loader_area] = + Layout::horizontal([Constraint::Length(1), Constraint::Length(11)]) + .areas(loader_area); + loader_area + }; + if props.show_search { let [table_area, search_area] = Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area); @@ -258,17 +292,183 @@ impl View for Browser { Constraint::Length(1), ]) .areas(search_area); + let loader_area = loader_area(table_area); self.notifications .render(RenderProps::from(table_area), frame); + if props.show_loading { + frame.render_widget(Clear, loader_area); + self.loader.render(RenderProps::from(loader_area), frame); + } + self.search .render(RenderProps::from(search_area).focus(render.focus), frame); } else { - self.notifications.render(render, frame); + let loader_area = loader_area(render.area); + + self.notifications.render(render.clone(), frame); + if props.show_loading { + frame.render_widget(Clear, loader_area); + self.loader.render(RenderProps::from(loader_area), frame); + } } } } +pub fn browser_page(_state: &State, channel: &Channel) -> AppWidget { + let tx = channel.tx.clone(); + + let content = Browser::new(tx.clone()) + .to_widget(tx.clone()) + .on_update(|state| BrowserProps::from(state).to_boxed_any().into()); + + let shortcuts = Shortcuts::default() + .to_widget(tx.clone()) + .on_update(|state: &State| { + let context = state.context.lock().unwrap(); + let shortcuts = if state.browser.show_search { + vec![("esc", "cancel"), ("enter", "apply")] + } else { + match context.mode.selection() { + SelectionMode::Id => vec![("enter", "select"), ("/", "search")], + SelectionMode::Operation => vec![ + ("enter", "show"), + ("c", "clear"), + ("r", "reload"), + ("/", "search"), + ("?", "help"), + ], + } + }; + + ShortcutsProps::default() + .shortcuts(&shortcuts) + .to_boxed_any() + .into() + }); + + Page::default() + .content(content) + .shortcuts(shortcuts) + .to_widget(tx.clone()) + .on_event(|key, _, props| { + let default = PageProps::default(); + let props = props + .and_then(|props| props.inner_ref::()) + .unwrap_or(&default); + + if props.handle_keys { + match key { + Key::Char('r') => Some(Message::Reload), + Key::Char('q') | Key::Ctrl('c') => Some(Message::Exit { selection: None }), + Key::Char('?') => Some(Message::OpenHelp), + _ => None, + } + } else { + None + } + }) + .on_update(|state: &State| { + PageProps::default() + .handle_keys(!state.browser.show_search) + .to_boxed_any() + .into() + }) +} + +pub fn help_page(_state: &State, channel: &Channel) -> AppWidget { + let tx = channel.tx.clone(); + + let content = Container::default() + .header(Header::default().to_widget(tx.clone()).on_update(|_| { + HeaderProps::default() + .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec()) + .to_boxed_any() + .into() + })) + .content( + TextView::default() + .to_widget(tx.clone()) + .on_event(|_, vs, _| { + vs.and_then(|vs| vs.unwrap_textview()) + .map(|tvs| Message::ScrollHelp { state: tvs }) + }) + .on_update(|state: &State| { + TextViewProps::default() + .state(Some(state.help.text.clone())) + .to_boxed_any() + .into() + }), + ) + .footer( + Footer::default() + .to_widget(tx.clone()) + .on_update(|state: &State| { + FooterProps::default() + .columns( + [ + Column::new(Text::raw(""), Constraint::Fill(1)), + Column::new( + span::default(&format!("{}%", state.help.text.scroll)).dim(), + Constraint::Min(4), + ), + ] + .to_vec(), + ) + .to_boxed_any() + .into() + }), + ) + .to_widget(tx.clone()); + + let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| { + ShortcutsProps::default() + .shortcuts(&[("?", "close")]) + .to_boxed_any() + .into() + }); + + Page::default() + .content(content) + .shortcuts(shortcuts) + .to_widget(tx.clone()) + .on_event(|key, _, _| match key { + Key::Char('q') | Key::Ctrl('c') => Some(Message::Exit { selection: None }), + Key::Char('?') => Some(Message::LeavePage), + _ => None, + }) + .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into()) +} + +pub fn help_text() -> String { + r#"# Generic keybindings + +`↑,k`: move cursor one line up +`↓,j: move cursor one line down +`PageUp`: move cursor one page up +`PageDown`: move cursor one page down +`Home`: move cursor to the first line +`End`: move cursor to the last line +`Esc`: Cancel +`q`: Quit + +# Specific keybindings + +`enter`: Select notification (if --mode id) +`enter`: Show notification +`r`: Reload notifications +`c`: Clear notification +`/`: Search +`?`: Show help + +# Searching + +Examples: state=unseen type=patch bugfix + type=(issue or patch) + state=unseen author=(did:key:... or did:key:...)"# + .into() +} + fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec> { let search = Line::from(vec![ span::default(" Search ").cyan().dim().reversed(), @@ -291,10 +491,21 @@ fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec> { span::default(&props.notifications.len().to_string()).dim(), ]); - match NotificationItemFilter::from_str(&props.search) - .unwrap_or_default() - .state() - { + let state = match NotificationFilter::from_str(&props.search).unwrap_or_default() { + NotificationFilter::State(state) => Some(state), + NotificationFilter::And(filters) => filters + .into_iter() + .filter_map(|f| match f { + NotificationFilter::State(state) => Some(state), + _ => None, + }) + .collect::>() + .first() + .cloned(), + _ => None, + }; + + match state { Some(state) => { let block = match state { NotificationState::Seen => seen, diff --git a/bin/ui/items.rs b/bin/ui/items.rs index 3f58b784..c405c6c5 100644 --- a/bin/ui/items.rs +++ b/bin/ui/items.rs @@ -1,3 +1,5 @@ +pub mod notification; + use std::collections::HashMap; use std::fmt; use std::fmt::Debug; @@ -12,17 +14,16 @@ 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; @@ -40,7 +41,6 @@ use tui_tree_widget::TreeItem; use radicle_tui as tui; -use tui::ui::theme::style; use tui::ui::utils::{LineMerger, MergeLocation}; use tui::ui::{span, Column}; use tui::ui::{ToRow, ToTree}; @@ -51,8 +51,84 @@ use crate::ui; use super::super::git; use super::format; -pub trait Filter { - fn matches(&self, item: &T) -> bool; +pub mod filter { + use std::fmt::{self, Write}; + use std::str::FromStr; + + use nom::bytes::complete::{tag_no_case, take}; + use nom::character::complete::{char, multispace0}; + use nom::combinator::map; + use nom::multi::separated_list1; + use nom::sequence::{delimited, tuple}; + use nom::IResult; + + use radicle::prelude::Did; + + /// A generic filter that needs be implemented for item filters in order to + /// apply it. + pub trait Filter { + fn matches(&self, item: &T) -> bool; + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum DidFilter { + Single(Did), + Or(Vec), + } + + impl fmt::Display for DidFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DidFilter::Single(did) => write!(f, "{did}")?, + DidFilter::Or(dids) => { + let mut it = dids.iter().peekable(); + f.write_char('(')?; + while let Some(did) = it.next() { + write!(f, "{did}")?; + if it.peek().is_none() { + write!(f, " or ")?; + } + } + f.write_char(')')?; + } + } + Ok(()) + } + } + + 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, + ))), + } + } + + pub fn parse_did_single(input: &str) -> IResult<&str, DidFilter> { + map(parse_did, DidFilter::Single)(input) + } + + pub fn parse_did_or(input: &str) -> IResult<&str, DidFilter> { + 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>| { + DidFilter::Or( + dids.iter() + .filter_map(|did| Did::from_str(did).ok()) + .collect::>(), + ) + }, + )(input) + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -81,407 +157,6 @@ impl AuthorItem { } } -#[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); - }; - ( - "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, 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, -} - -#[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 = if !self.authors.is_empty() { - { - self.authors - .iter() - .any(|other| notif.author.nid == Some(**other)) - } - } else { - 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. @@ -598,7 +273,7 @@ impl IssueItemFilter { } } -impl Filter for IssueItemFilter { +impl filter::Filter for IssueItemFilter { fn matches(&self, issue: &IssueItem) -> bool { use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; @@ -850,7 +525,7 @@ impl PatchItemFilter { } } -impl Filter for PatchItemFilter { +impl filter::Filter for PatchItemFilter { fn matches(&self, patch: &PatchItem) -> bool { use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; @@ -1758,7 +1433,7 @@ impl Debug for HunkItem<'_> { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct StatefulHunkItem<'a>(HunkItem<'a>, HunkState); impl<'a> StatefulHunkItem<'a> { @@ -2027,26 +1702,6 @@ mod tests { Ok(()) } - #[test] - fn notification_item_filter_from_str_should_succeed() -> Result<()> { - let search = r#"is:seen is:patch authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#; - let actual = NotificationItemFilter::from_str(search)?; - - let expected = NotificationItemFilter { - state: Some(NotificationState::Seen), - type_name: Some(NotificationType::Patch), - authors: vec![ - Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?, - Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?, - ], - search: Some("cli".to_string()), - }; - - assert_eq!(expected, actual); - - Ok(()) - } - #[test] fn diff_line_index_checks_ranges_correctly() -> Result<()> { let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap(); diff --git a/bin/ui/items/notification.rs b/bin/ui/items/notification.rs new file mode 100644 index 00000000..3dbf7d14 --- /dev/null +++ b/bin/ui/items/notification.rs @@ -0,0 +1,793 @@ +use std::fmt; + +use radicle::cob::{ObjectId, Timestamp, TypeName, TypedId}; +use radicle::identity::Identity; +use radicle::issue::Issues; +use radicle::node; +use radicle::patch::Patches; +use radicle::prelude::Project; +use radicle::storage::git::Repository; +use radicle::storage::{ReadRepository, RefUpdate}; +use radicle::Profile; + +use ratatui::style::Stylize; +use ratatui::widgets::Cell; + +use radicle_tui as tui; + +use tui::ui::span; +use tui::ui::theme::style; +use tui::ui::ToRow; + +use super::AuthorItem; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NotificationState { + Seen, + Unseen, +} + +impl fmt::Display for NotificationState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationState::Seen => write!(f, "seen")?, + NotificationState::Unseen => write!(f, "unseen")?, + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub enum NotificationKind { + Cob { + type_name: Option, + summary: Option, + status: Option, + id: Option, + }, + Branch { + name: Option, + summary: Option, + status: Option, + _id: Option, + }, + Unknown { + refname: Option, + }, +} + +impl PartialEq for NotificationKind { + fn eq(&self, other: &Self) -> bool { + match self { + NotificationKind::Cob { type_name, .. } => { + let other_type_name = match other { + NotificationKind::Cob { type_name, .. } => type_name.clone(), + _ => None, + }; + other_type_name.is_some() && (other_type_name == *type_name) + } + NotificationKind::Branch { name, .. } => { + let other_name = match other { + NotificationKind::Branch { name, .. } => name.clone(), + _ => None, + }; + other_name.is_some() && (other_name == *name) + } + NotificationKind::Unknown { refname } => { + let other_refname = match other { + NotificationKind::Unknown { refname, .. } => refname.clone(), + _ => None, + }; + other_refname.is_some() && (other_refname == *refname) + } + } + } +} + +impl fmt::Display for NotificationKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationKind::Cob { type_name, .. } => { + write!(f, "cob")?; + if let Some(type_name) = type_name { + write!(f, ":{type_name}")?; + } + } + NotificationKind::Branch { name, .. } => { + write!(f, "branch")?; + if let Some(name) = name { + write!(f, ":{name}")?; + } + } + NotificationKind::Unknown { refname, .. } => { + write!(f, "unknown")?; + if let Some(refname) = refname { + write!(f, ":{refname}")?; + } + } + } + Ok(()) + } +} + +impl NotificationKind { + pub fn new( + repo: &Repository, + notification: &node::notifications::Notification, + ) -> Result, anyhow::Error> { + // TODO: move out of here + let issues = Issues::open(repo)?; + let patches = Patches::open(repo)?; + + match ¬ification.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: Some(name.to_string()), + summary: Some(message), + status: Some(status.to_string()), + _id: head.map(ObjectId::from), + })) + } + node::notifications::NotificationKind::Cob { typed_id } => { + let TypedId { id, type_name } = typed_id; + let (type_name, 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); + }; + ( + type_name, + issue.title().to_string(), + 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); + }; + ( + type_name, + patch.title().to_string(), + patch.state().to_string(), + ) + } else if typed_id.is_identity() { + let Ok(identity) = Identity::get(id, repo) else { + log::error!( + target: "items", + "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: "items", + "Error retrieving identity revision for notification {}", notification.id + ); + return Ok(None); + }; + (type_name, rev.title.to_string(), rev.state.to_string()) + } else { + (type_name, "".to_string(), "".to_string()) + }; + + Ok(Some(NotificationKind::Cob { + type_name: Some(type_name.clone()), + summary: Some(summary.to_string()), + status: Some(state.to_string()), + id: Some(*id), + })) + } + node::notifications::NotificationKind::Unknown { refname } => { + Ok(Some(NotificationKind::Unknown { + refname: Some(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, + project: &Project, + repo: &Repository, + notification: &node::notifications::Notification, + ) -> Result, anyhow::Error> { + 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: _, + } => ( + Some("branch".to_string()), + summary.clone(), + status.clone(), + name.clone(), + ), + NotificationKind::Cob { + type_name, + summary, + status, + id, + } => { + let id = id.map(|id| super::format::cob(&id)).unwrap_or_default(); + ( + type_name.as_ref().map(|t| t.to_string()), + summary.clone(), + status.clone(), + Some(id.to_string()), + ) + } + NotificationKind::Unknown { refname } => (refname.clone(), None, None, None), + }; + + 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.unwrap_or_default()); + let summary = span::default(&summary.unwrap_or_default()); + let type_name = span::notification_type(&type_name.unwrap_or_default()); + let name = span::default(&self.project.clone()).style(style::gray().dim()); + + let status = status.unwrap_or_default(); + 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(), + ] + } +} + +pub mod filter { + use std::fmt; + use std::fmt::Write as _; + use std::str::FromStr; + + use nom::branch::alt; + use nom::bytes::complete::{tag, tag_no_case, take_while1}; + use nom::character::complete::{char, multispace0}; + use nom::combinator::{map, opt, value}; + use nom::multi::{many0, separated_list1}; + use nom::sequence::{delimited, preceded, tuple}; + use nom::IResult; + use radicle::cob::TypeName; + + use crate::ui::items::filter; + use crate::ui::items::filter::{DidFilter, Filter}; + + use super::{Notification, NotificationKind, NotificationState}; + + #[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", + } + } + } + + #[derive(Debug, Clone, PartialEq)] + pub enum NotificationFilter { + State(NotificationState), + Kind(NotificationKindFilter), + Author(DidFilter), + Search(String), + And(Vec), + Empty, + Invalid, + } + + impl Default for NotificationFilter { + fn default() -> Self { + NotificationFilter::And(vec![ + NotificationFilter::Kind(NotificationKindFilter::Or(vec![ + NotificationKind::Cob { + type_name: TypeName::from_str("xyz.radicle.patch").ok(), + summary: None, + status: None, + id: None, + }, + NotificationKind::Cob { + type_name: TypeName::from_str("xyz.radicle.issue").ok(), + summary: None, + status: None, + id: None, + }, + ])), + NotificationFilter::State(NotificationState::Unseen), + ]) + } + } + + impl fmt::Display for NotificationFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationFilter::State(state) => { + write!(f, "state={state}")?; + f.write_char(' ')?; + } + NotificationFilter::Kind(filter) => { + write!(f, "kind={filter}")?; + f.write_char(' ')?; + } + NotificationFilter::Author(filter) => { + write!(f, "author={filter}")?; + f.write_char(' ')?; + } + NotificationFilter::Search(search) => { + write!(f, "{search}")?; + f.write_char(' ')?; + } + NotificationFilter::And(filters) => { + let mut it = filters.iter().peekable(); + while let Some(filter) = it.next() { + write!(f, "{filter}")?; + if it.peek().is_none() { + f.write_char(' ')?; + } + } + } + NotificationFilter::Empty | NotificationFilter::Invalid => {} + } + + Ok(()) + } + } + + #[derive(Debug, Clone, PartialEq)] + pub enum NotificationKindFilter { + Single(NotificationKind), + Or(Vec), + } + + impl fmt::Display for NotificationKindFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationKindFilter::Single(type_name) => write!(f, "{type_name}")?, + NotificationKindFilter::Or(type_names) => { + let mut it = type_names.iter().peekable(); + f.write_char('(')?; + while let Some(type_name) = it.next() { + write!(f, "{type_name}")?; + if it.peek().is_some() { + write!(f, " or ")?; + } + } + f.write_char(')')?; + } + } + Ok(()) + } + } + + impl Filter for NotificationFilter { + fn matches(&self, notif: &Notification) -> bool { + use fuzzy_matcher::skim::SkimMatcherV2; + use fuzzy_matcher::FuzzyMatcher; + + let matcher = SkimMatcherV2::default(); + + match self { + NotificationFilter::State(state) => match state { + NotificationState::Seen => notif.seen, + NotificationState::Unseen => !notif.seen, + }, + NotificationFilter::Kind(type_filter) => match type_filter { + NotificationKindFilter::Single(kind) => ¬if.kind == kind, + NotificationKindFilter::Or(kinds) => { + kinds.iter().any(|kind| ¬if.kind == kind) + } + }, + NotificationFilter::Author(author_filter) => match author_filter { + DidFilter::Single(author) => notif.author.nid == Some(**author), + DidFilter::Or(authors) => authors + .iter() + .any(|other| notif.author.nid == Some(**other)), + }, + NotificationFilter::Search(search) => { + let summary = match ¬if.kind { + NotificationKind::Cob { + type_name: _, + summary, + status: _, + id: _, + } => summary.clone().unwrap_or_default(), + NotificationKind::Branch { + name: _, + summary, + status: _, + _id: _, + } => summary.clone().unwrap_or_default(), + NotificationKind::Unknown { refname } => { + refname.clone().unwrap_or_default() + } + }; + match matcher.fuzzy_match(&summary, search) { + Some(score) => score == 0 || score > 60, + _ => false, + } + } + NotificationFilter::And(filters) => filters.iter().all(|f| f.matches(notif)), + NotificationFilter::Empty => true, + NotificationFilter::Invalid => false, + } + } + } + + impl FromStr for NotificationFilter { + type Err = anyhow::Error; + + fn from_str(filter_exp: &str) -> Result { + 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_name(input: &str) -> IResult<&str, &str> { + take_while1(|c: char| { + c.is_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '/' + })(input) + } + + fn parse_type_name(input: &str) -> IResult<&str, TypeName> { + let (input, type_name) = + take_while1(|c: char| c.is_alphanumeric() || c == '.')(input)?; + + match TypeName::from_str(type_name) { + Ok(t) => IResult::Ok((input, t)), + Err(_) => IResult::Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Verify, + ))), + } + } + + 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_cob_kind(input: &str) -> IResult<&str, NotificationKind> { + let (input, _) = tag("cob")(input)?; + let (input, type_name) = opt(preceded(tag(":"), parse_type_name))(input)?; + + Ok(( + input, + NotificationKind::Cob { + type_name, + summary: None, + status: None, + id: None, + }, + )) + } + + fn parse_branch_kind(input: &str) -> IResult<&str, NotificationKind> { + let (input, _) = tag("branch")(input)?; + let (input, name) = opt(preceded(tag(":"), parse_name))(input)?; + + Ok(( + input, + NotificationKind::Branch { + name: name.map(|n| n.to_string()), + summary: None, + status: None, + _id: None, + }, + )) + } + + fn parse_unknown_kind(input: &str) -> IResult<&str, NotificationKind> { + let (input, _) = tag("unknown")(input)?; + let (input, refname) = opt(preceded(tag(":"), parse_name))(input)?; + + Ok(( + input, + NotificationKind::Unknown { + refname: refname.map(|r| r.to_string()), + }, + )) + } + + fn parse_kind(input: &str) -> IResult<&str, NotificationKind> { + alt((parse_cob_kind, parse_branch_kind, parse_unknown_kind))(input) + } + + fn parse_kind_single(input: &str) -> IResult<&str, NotificationKindFilter> { + map(parse_kind, NotificationKindFilter::Single)(input) + } + + fn parse_kind_or(input: &str) -> IResult<&str, NotificationKindFilter> { + map( + delimited( + tuple((multispace0, char('('), multispace0)), + separated_list1( + delimited(multispace0, tag_no_case("or"), multispace0), + parse_kind, + ), + tuple((multispace0, char(')'), multispace0)), + ), + NotificationKindFilter::Or, + )(input) + } + + fn parse_kind_filter(input: &str) -> IResult<&str, NotificationFilter> { + map( + preceded( + tuple(( + tag_no_case("kind"), + multispace0, + tag_no_case("="), + multispace0, + )), + alt((parse_kind_or, parse_kind_single)), + ), + NotificationFilter::Kind, + )(input) + } + + fn parse_author_filter(input: &str) -> IResult<&str, NotificationFilter> { + map( + preceded( + tuple(( + tag_no_case("author"), + multispace0, + tag_no_case("="), + multispace0, + )), + alt((filter::parse_did_single, filter::parse_did_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_kind_filter, + parse_author_filter, + parse_search_filter, + ))(input) + } + + fn parse_filters(input: &str) -> IResult<&str, Vec> { + many0(preceded(multispace0, parse_single_filter))(input) + } + + let parse_filter_expression = |input: &str| -> Result { + 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 Ok(NotificationFilter::Empty); + } + + 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 std::str::FromStr; + + use anyhow::Result; + use radicle::prelude::Did; + + use crate::ui::items::filter::DidFilter; + + use super::filter::*; + use super::*; + + #[test] + fn notification_item_filter_with_concrete_kind_should_succeed() -> Result<()> { + let search = r#"kind=cob:xyz.radicle.patch"#; + let actual = NotificationFilter::from_str(search)?; + + let expected = + NotificationFilter::Kind(NotificationKindFilter::Single(NotificationKind::Cob { + type_name: TypeName::from_str("xyz.radicle.patch").ok(), + summary: None, + status: None, + id: None, + })); + + assert_eq!(actual, expected); + + 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(DidFilter::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 kind=(cob:xyz.radicle.patch or cob:xyz.radicle.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::Kind(NotificationKindFilter::Or(vec![ + NotificationKind::Cob { + type_name: TypeName::from_str("xyz.radicle.patch").ok(), + summary: None, + status: None, + id: None, + }, + NotificationKind::Cob { + type_name: TypeName::from_str("xyz.radicle.issue").ok(), + summary: None, + status: None, + id: None, + }, + ])), + NotificationFilter::Author(DidFilter::Or(vec![ + Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?, + Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?, + ])), + NotificationFilter::Search("cli".to_string()), + ]); + + assert_eq!(expected, actual); + + Ok(()) + } +}