From f5d7d0fd397b07b1e9da8a9aa346b18204da22cb Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Thu, 29 Feb 2024 16:39:40 +0100 Subject: [PATCH] inbox: Implement listing all / per-repo --- bin/commands/inbox.rs | 25 +++- bin/commands/inbox/common.rs | 38 +++++- bin/commands/inbox/flux/select.rs | 93 ++++++++++++--- bin/commands/inbox/flux/select/ui.rs | 172 +++++++++++++++++---------- src/flux/ui/cob.rs | 31 ++++- 5 files changed, 274 insertions(+), 85 deletions(-) diff --git a/bin/commands/inbox.rs b/bin/commands/inbox.rs index 417811cf..7003b00d 100644 --- a/bin/commands/inbox.rs +++ b/bin/commands/inbox.rs @@ -18,6 +18,8 @@ use tui::common::cob::inbox::{self}; use crate::terminal; use crate::terminal::args::{Args, Error, Help}; +use self::common::{Mode, RepositoryMode, SelectionMode}; + pub const HELP: Help = Help { name: "inbox", description: "Terminal interfaces for notifications", @@ -55,7 +57,7 @@ pub enum OperationName { #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SelectOptions { - mode: common::Mode, + mode: Mode, filter: inbox::Filter, sort_by: inbox::SortBy, } @@ -66,6 +68,7 @@ impl Args for Options { let mut parser = lexopt::Parser::from_args(args); let mut op: Option = None; + let mut repository_mode = None; let mut reverse = None; let mut field = None; let mut select_opts = SelectOptions::default(); @@ -81,11 +84,12 @@ impl Args for Options { let val = parser.value()?; let val = val.to_str().unwrap_or_default(); - select_opts.mode = match val { - "operation" => common::Mode::Operation, - "id" => common::Mode::Id, + let selection_mode = match val { + "operation" => SelectionMode::Operation, + "id" => SelectionMode::Id, unknown => anyhow::bail!("unknown mode '{}'", unknown), }; + select_opts.mode = select_opts.mode.with_selection(selection_mode) } Long("reverse") | Short('r') => { @@ -101,6 +105,16 @@ impl Args for Options { } } + Long("repo") if repository_mode.is_none() && op.is_some() => { + let val = parser.value()?; + let repo = terminal::args::rid(&val)?; + + repository_mode = Some(RepositoryMode::ByRepo((repo, None))); + } + Long("all") | Short('a') if repository_mode.is_none() => { + repository_mode = Some(RepositoryMode::All); + } + Value(val) if op.is_none() => match val.to_string_lossy().as_ref() { "select" => op = Some(OperationName::Select), unknown => anyhow::bail!("unknown operation '{}'", unknown), @@ -109,6 +123,9 @@ impl Args for Options { } } + select_opts.mode = select_opts + .mode + .with_repository(repository_mode.unwrap_or_default()); select_opts.sort_by = if let Some(field) = field { inbox::SortBy { field, diff --git a/bin/commands/inbox/common.rs b/bin/commands/inbox/common.rs index a3185c39..6791c752 100644 --- a/bin/commands/inbox/common.rs +++ b/bin/commands/inbox/common.rs @@ -1,10 +1,46 @@ +use radicle::identity::RepoId; + /// 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(Debug, Default, Clone, PartialEq, Eq)] -pub enum Mode { +pub enum SelectionMode { Id, #[default] Operation, } + +#[derive(Clone, Default, Debug, PartialEq, Eq)] +pub enum RepositoryMode { + #[default] + Contextual, + All, + ByRepo((RepoId, Option)), +} + +#[derive(Clone, Default, Debug, PartialEq, Eq)] +pub struct Mode { + selection: SelectionMode, + repository: RepositoryMode, +} + +impl Mode { + pub fn with_selection(mut self, selection: SelectionMode) -> Self { + self.selection = selection; + self + } + + pub fn with_repository(mut self, repository: RepositoryMode) -> Self { + self.repository = repository; + self + } + + pub fn selection(&self) -> &SelectionMode { + &self.selection + } + + pub fn repository(&self) -> &RepositoryMode { + &self.repository + } +} diff --git a/bin/commands/inbox/flux/select.rs b/bin/commands/inbox/flux/select.rs index 6af6d48f..a0848f81 100644 --- a/bin/commands/inbox/flux/select.rs +++ b/bin/commands/inbox/flux/select.rs @@ -6,6 +6,7 @@ use anyhow::Result; use radicle::identity::Project; use radicle::node::notifications::NotificationId; use radicle::storage::ReadRepository; +use radicle::storage::ReadStorage; use radicle::storage::git::Repository; use radicle::Profile; @@ -20,14 +21,14 @@ use tui::Exit; use ui::ListPage; -use super::super::common; +use super::super::common::{Mode, RepositoryMode}; type Selection = tui::Selection; pub struct Context { pub profile: Profile, pub repository: Repository, - pub mode: common::Mode, + pub mode: Mode, pub filter: inbox::Filter, pub sort_by: inbox::SortBy, } @@ -40,7 +41,7 @@ pub struct App { pub struct InboxState { notifications: Vec, selected: Option, - mode: common::Mode, + mode: Mode, project: Project, } @@ -51,33 +52,89 @@ impl TryFrom<&Context> for InboxState { let doc = context.repository.identity_doc()?; let project = doc.project()?; - let notifications = inbox::all(&context.repository, &context.profile)?; - let mut items = vec![]; + let mut notifications = match &context.mode.repository() { + RepositoryMode::All => { + let mut repos = context.profile.storage.repositories()?; + repos.sort_by_key(|r| r.rid); - // Convert into UI items - for notif in ¬ifications { - if let Ok(notif) = - NotificationItem::try_from((&context.profile, &context.repository, notif)) - { - items.push(notif); + 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::try_from((&context.profile, &repo, notif))) + .filter_map(|item| item.ok()) + .collect::>(); + + notifs.extend(items); + } + + notifs } - } + RepositoryMode::Contextual => { + let notifs = inbox::all(&context.repository, &context.profile)?; + + notifs + .iter() + .map(|notif| { + NotificationItem::try_from((&context.profile, &context.repository, notif)) + }) + .filter_map(|item| item.ok()) + .collect::>() + } + RepositoryMode::ByRepo((rid, _)) => { + let repo = context.profile.storage.repository(*rid)?; + let notifs = inbox::all(&repo, &context.profile)?; + + notifs + .iter() + .map(|notif| NotificationItem::try_from((&context.profile, &repo, notif))) + .filter_map(|item| item.ok()) + .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" => items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)), - "id" => items.sort_by(|a, b| a.id.cmp(&b.id)), + "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 { - items.reverse(); + notifications.reverse(); } - let selected = items.first().cloned(); + + // Sort by project if all notifications are shown + if let RepositoryMode::All = mode.repository() { + notifications.sort_by(|a, b| a.project.cmp(&b.project)); + } + + let selected = notifications.first().cloned(); Ok(Self { - notifications: items, + notifications, selected, - mode: context.mode.clone(), + mode: mode.clone(), project, }) } diff --git a/bin/commands/inbox/flux/select/ui.rs b/bin/commands/inbox/flux/select/ui.rs index a05063df..0a89f7c4 100644 --- a/bin/commands/inbox/flux/select/ui.rs +++ b/bin/commands/inbox/flux/select/ui.rs @@ -22,7 +22,8 @@ use tui::flux::ui::widget::{ }; use tui::Selection; -use super::common::Mode; +use crate::tui_inbox::common::{Mode, RepositoryMode, SelectionMode}; + use super::{Action, InboxState}; pub struct ListPageProps { @@ -87,11 +88,11 @@ impl Widget for ListPage { } Key::Char('\n') => { if let Some(selected) = &self.props.selected { - let selection = match self.props.mode { - Mode::Operation => Selection::default() + let selection = match self.props.mode.selection() { + SelectionMode::Operation => Selection::default() .with_operation("show".to_string()) .with_id(selected.id), - Mode::Id => Selection::default().with_id(selected.id), + SelectionMode::Id => Selection::default().with_id(selected.id), }; let _ = self.action_tx.send(Action::Exit { selection: Some(selection), @@ -124,12 +125,11 @@ impl Render<()> for ListPage { let area = frame.size(); let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16); - let shortcuts = match self.props.mode { - Mode::Id => vec![Shortcut::new("enter", "select")], - Mode::Operation => vec![ - Shortcut::new("enter", "show"), - Shortcut::new("c", "clear"), - ], + let shortcuts = match self.props.mode.selection() { + SelectionMode::Id => vec![Shortcut::new("enter", "select")], + SelectionMode::Operation => { + vec![Shortcut::new("enter", "show"), Shortcut::new("c", "clear")] + } }; self.notifications.render::(frame, layout.component, ()); @@ -146,8 +146,12 @@ impl Render<()> for ListPage { struct NotificationsProps { notifications: Vec, + mode: Mode, project: Project, stats: HashMap, + cutoff: usize, + cutoff_after: usize, + focus: bool, } impl From<&InboxState> for NotificationsProps { @@ -166,8 +170,12 @@ impl From<&InboxState> for NotificationsProps { Self { notifications: state.notifications.clone(), + mode: state.mode.clone(), project: state.project.clone(), stats, + cutoff: 200, + cutoff_after: 5, + focus: false, } } } @@ -250,32 +258,83 @@ impl Widget for Notifications { } } -impl Render<()> for Notifications { - fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) { - let cutoff = 200; - let cutoff_after = 8; - let focus = false; +impl Notifications { + fn render_header(&self, frame: &mut ratatui::Frame, area: Rect) { + let title = match self.props.mode.repository() { + RepositoryMode::Contextual => self.props.project.name().to_string(), + RepositoryMode::All => "All repositories".to_string(), + RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(), + }; - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints(vec![ + self.header.render::( + frame, + area, + HeaderProps { + cells: [String::from("").into(), title.into()], + widths: [Constraint::Length(0), Constraint::Fill(1)], + focus: self.props.focus, + cutoff: self.props.cutoff, + cutoff_after: self.props.cutoff_after, + }, + ); + } + + fn render_table(&self, frame: &mut ratatui::Frame, area: Rect) { + if let RepositoryMode::All = self.props.mode.repository() { + let widths = [ + Constraint::Length(5), Constraint::Length(3), - Constraint::Min(1), + Constraint::Length(15), + Constraint::Length(25), + Constraint::Fill(1), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(15), + Constraint::Length(18), + ]; + + self.table.render::( + frame, + area, + TableProps { + items: self.props.notifications.to_vec(), + has_header: true, + has_footer: true, + widths, + focus: self.props.focus, + cutoff: self.props.cutoff, + cutoff_after: self.props.cutoff_after.saturating_add(1), + }, + ); + } else { + let widths = [ + Constraint::Length(5), Constraint::Length(3), - ]) - .split(area); + Constraint::Length(25), + Constraint::Fill(1), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(15), + Constraint::Length(18), + ]; - let widths = [ - Constraint::Length(5), - Constraint::Length(3), - Constraint::Length(20), - Constraint::Fill(1), - Constraint::Length(8), - Constraint::Length(10), - Constraint::Length(15), - Constraint::Length(18), - ]; + self.table.render::( + frame, + area, + TableProps { + items: self.props.notifications.to_vec(), + has_header: true, + has_footer: true, + widths, + focus: self.props.focus, + cutoff: self.props.cutoff, + cutoff_after: self.props.cutoff_after, + }, + ); + } + } + fn render_footer(&self, frame: &mut ratatui::Frame, area: Rect) { let filter = Line::from([span::blank()].to_vec()); let stats = Line::from( [ @@ -294,35 +353,9 @@ impl Render<()> for Notifications { let (step, len) = self.table.progress(self.props.notifications.len()); let progress = span::progress(step, len, false); - self.header.render::( - frame, - layout[0], - HeaderProps { - cells: [String::from("").into(), self.props.project.name().into()], - widths: [Constraint::Length(0), Constraint::Fill(1)], - focus, - cutoff, - cutoff_after, - }, - ); - - self.table.render::( - frame, - layout[1], - TableProps { - items: self.props.notifications.to_vec(), - has_header: true, - has_footer: true, - focus, - widths, - cutoff, - cutoff_after, - }, - ); - self.footer.render::( frame, - layout[2], + area, FooterProps { cells: [filter.into(), stats.into(), progress.clone().into()], widths: [ @@ -330,10 +363,27 @@ impl Render<()> for Notifications { Constraint::Fill(1), Constraint::Length(progress.width() as u16), ], - focus, - cutoff, - cutoff_after, + focus: self.props.focus, + cutoff: self.props.cutoff, + cutoff_after: self.props.cutoff_after, }, ); } } + +impl Render<()> for Notifications { + fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(area); + + self.render_header::(frame, layout[0]); + self.render_table::(frame, layout[1]); + self.render_footer::(frame, layout[2]); + } +} diff --git a/src/flux/ui/cob.rs b/src/flux/ui/cob.rs index deb7cb9e..eb24ef12 100644 --- a/src/flux/ui/cob.rs +++ b/src/flux/ui/cob.rs @@ -12,8 +12,9 @@ use radicle::node::notifications::{Notification, NotificationId, NotificationKin use radicle::node::AliasStore; use radicle::patch::{Patch, PatchId, Patches}; use radicle::storage::git::Repository; -use radicle::storage::{ReadRepository, RefUpdate}; +use radicle::storage::{ReadRepository, ReadStorage, RefUpdate}; +use super::theme::style; use super::widget::ToRow; use super::{format, span}; @@ -133,6 +134,8 @@ impl TryFrom<(&Repository, &Notification)> for NotificationKindItem { 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. @@ -148,10 +151,17 @@ impl TryFrom<(&Profile, &Repository, &Notification)> for NotificationItem { fn try_from(value: (&Profile, &Repository, &Notification)) -> Result { let (profile, repo, notification) = value; + let project = profile + .storage + .repository(repo.id)? + .identity_doc()? + .project()?; + let name = project.name().to_string(); let kind = NotificationKindItem::try_from((repo, notification))?; Ok(NotificationItem { id: notification.id, + project: name, seen: notification.status.is_read(), kind, author: AuthorItem::new(notification.remote, profile), @@ -227,6 +237,25 @@ impl ToRow<8> for NotificationItem { } } +impl ToRow<9> for NotificationItem { + fn to_row(&self) -> [Cell; 9] { + let row: [Cell; 8] = self.to_row(); + let name = span::default(self.project.clone()).style(style::gray().dim()); + + [ + row[0].clone(), + row[1].clone(), + name.into(), + row[2].clone(), + row[3].clone(), + row[4].clone(), + row[5].clone(), + row[6].clone(), + row[7].clone(), + ] + } +} + #[derive(Clone, Debug)] pub struct IssueItem { /// Issue OID.