mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-09 13:20:49 +08:00
inbox: Implement listing all / per-repo
This commit is contained in:
+21
-4
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ¬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::<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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user