inbox: Implement listing all / per-repo

This commit is contained in:
Erik Kundt
2024-03-01 12:42:41 +01:00
parent 2a6f390ea5
commit f5d7d0fd39
5 changed files with 274 additions and 85 deletions
+21 -4
View File
@@ -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<OperationName> = 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,
+37 -1
View File
@@ -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<String>)),
}
#[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
}
}
+75 -18
View File
@@ -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<NotificationId>;
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<NotificationItem>,
selected: Option<NotificationItem>,
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 &notifications {
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::<Vec<_>>();
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::<Vec<_>>()
}
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::<Vec<_>>()
}
};
// 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,
})
}
+111 -61
View File
@@ -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<InboxState, Action> 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::<B>(frame, layout.component, ());
@@ -146,8 +146,12 @@ impl Render<()> for ListPage {
struct NotificationsProps {
notifications: Vec<NotificationItem>,
mode: Mode,
project: Project,
stats: HashMap<String, usize>,
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<InboxState, Action> for Notifications {
}
}
impl Render<()> for Notifications {
fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
let cutoff = 200;
let cutoff_after = 8;
let focus = false;
impl Notifications {
fn render_header<B: Backend>(&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::<B>(
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<B: Backend>(&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::<B>(
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::<B>(
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<B: Backend>(&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::<B>(
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::<B>(
frame,
layout[1],
TableProps {
items: self.props.notifications.to_vec(),
has_header: true,
has_footer: true,
focus,
widths,
cutoff,
cutoff_after,
},
);
self.footer.render::<B>(
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<B: Backend>(&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::<B>(frame, layout[0]);
self.render_table::<B>(frame, layout[1]);
self.render_footer::<B>(frame, layout[2]);
}
}
+30 -1
View File
@@ -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<Self, Self::Error> {
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.