bin/inbox: Improve item loading and filtering

This commit is contained in:
Erik Kundt
2025-12-06 12:30:45 +01:00
parent d962ec4b42
commit e6ddafbc5f
7 changed files with 1386 additions and 843 deletions
-1
View File
@@ -1,3 +1,2 @@
pub mod inbox;
pub mod issue;
pub mod patch;
-38
View File
@@ -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<Vec<Notification>> {
let all = profile
.notifications_mut()?
.by_repo(&repository.id, "timestamp")?
.collect::<Vec<_>>();
let mut notifications = vec![];
for n in all {
let n = n?;
notifications.push(n);
}
Ok(notifications)
}
+11 -8
View File
@@ -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
+256 -336
View File
@@ -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<NotificationId>;
#[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<Option<Selection>> {
let channel = Channel::default();
let state = State::new(context.clone())?;
let tx = channel.tx.clone();
#[derive(Clone, Debug)]
pub struct BrowserState {
items: Vec<NotificationItem>,
selected: Option<usize>,
filter: NotificationItemFilter,
search: BufferedValue<String>,
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<NotificationItem> {
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<AppPage>,
browser: BrowserState,
help: HelpState,
}
impl TryFrom<&Context> for State {
type Error = anyhow::Error;
fn try_from(context: &Context) -> Result<Self, Self::Error> {
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::<Vec<_>>();
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::<Vec<_>>()
}
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::<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" => 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<Notification>),
}
#[derive(Clone, Debug)]
pub struct BrowserState {
items: Arc<Mutex<Vec<Notification>>>,
selected: Option<usize>,
filter: NotificationFilter,
search: BufferedValue<String>,
show_search: bool,
is_loading: bool,
}
impl BrowserState {
fn items(&self) -> Vec<Notification> {
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<Notification>) {
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<PageState>,
browser: BrowserState,
help: HelpState,
context: Arc<Mutex<Context>>,
}
impl State {
fn new(context: Context) -> Result<Self, anyhow::Error> {
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<Message> for State {
@@ -233,11 +206,14 @@ impl store::Update<Message> 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<Message> 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<Message> 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<Option<Selection>> {
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<Message>) -> Widget<State, Message> {
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<Vec<Self::Return>> {
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::<PageProps>())
.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<Message>) -> Widget<State, Message> {
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,
&notif,
)
.to_boxed_any()
.into()
}),
)
.to_widget(tx.clone());
})
.filter_map(|notif| notif.ok())
.flatten()
.collect::<Vec<_>>()
}
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,
&notif,
)
})
.filter_map(|notif| notif.ok())
.flatten()
.collect::<Vec<_>>()
}
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,
&notif,
)
})
.filter_map(|notif| notif.ok())
.flatten()
.collect::<Vec<_>>()
}
};
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:<state> | is:patch | is:issue | <search>
Example: is:unseen is:patch Print"#
.into()
impl Process<Message> for Loader {
async fn process(&mut self, message: Message) -> anyhow::Result<Vec<Message>> {
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![]),
}
}
}
+238 -27
View File
@@ -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<State, Message>;
type AppWidget = widget::Widget<State, Message>;
#[derive(Clone, Default)]
pub struct BrowserProps<'a> {
@@ -38,7 +43,7 @@ pub struct BrowserProps<'a> {
/// Table title
header: String,
/// Filtered notifications.
notifications: Vec<NotificationItem>,
notifications: Vec<Notification>,
/// Current (selected) table index
selected: Option<usize>,
/// 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<Message>) -> Self {
pub fn new(tx: broadcast::Sender<Message>) -> Self {
Self {
notifications: Container::default()
.header(Header::default().to_widget(tx.clone()).on_update(|state| {
@@ -126,7 +138,7 @@ impl Browser {
.into()
}))
.content(
Table::<State, Message, NotificationItem, 9>::default()
Table::<State, Message, Notification, 9>::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::<BrowserProps>())
.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<Message>) -> 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::<PageProps>())
.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<Message>) -> 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<Column<'a>> {
let search = Line::from(vec![
span::default(" Search ").cyan().dim().reversed(),
@@ -291,10 +491,21 @@ fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
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::<Vec<_>>()
.first()
.cloned(),
_ => None,
};
match state {
Some(state) => {
let block = match state {
NotificationState::Seen => seen,
+88 -433
View File
@@ -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<T> {
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<T> {
fn matches(&self, item: &T) -> bool;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DidFilter {
Single(Did),
Or(Vec<Did>),
}
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::<Vec<_>>(),
)
},
)(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<ObjectId>,
},
Cob {
type_name: String,
summary: String,
status: String,
id: Option<ObjectId>,
},
Unknown {
refname: String,
},
}
impl NotificationKindItem {
pub fn new(
repo: &Repository,
notification: &Notification,
) -> Result<Option<Self>, anyhow::Error> {
// TODO: move out of here
let issues = Issues::open(repo)?;
let patches = Patches::open(repo)?;
match &notification.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<Option<Self>, 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<NotificationState>,
type_name: Option<NotificationType>,
authors: Vec<Did>,
search: Option<String>,
}
impl NotificationItemFilter {
pub fn state(&self) -> Option<NotificationState> {
self.state.clone()
}
}
impl Filter<NotificationItem> 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!(&notif.kind, NotificationKindItem::Cob {
type_name,
summary: _,
status: _,
id: _,
} if type_name == "patch"),
Some(NotificationType::Issue) => matches!(&notif.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 &notif.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<Self, Self::Err> {
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<IssueItem> for IssueItemFilter {
impl filter::Filter<IssueItem> 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<PatchItem> for PatchItemFilter {
impl filter::Filter<PatchItem> 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();
+793
View File
@@ -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<TypeName>,
summary: Option<String>,
status: Option<String>,
id: Option<ObjectId>,
},
Branch {
name: Option<String>,
summary: Option<String>,
status: Option<String>,
_id: Option<ObjectId>,
},
Unknown {
refname: Option<String>,
},
}
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<Option<Self>, anyhow::Error> {
// TODO: move out of here
let issues = Issues::open(repo)?;
let patches = Patches::open(repo)?;
match &notification.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<Option<Self>, 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<NotificationFilter>),
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<NotificationKind>),
}
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<Notification> 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) => &notif.kind == kind,
NotificationKindFilter::Or(kinds) => {
kinds.iter().any(|kind| &notif.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 &notif.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<Self, Self::Err> {
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<NotificationFilter>> {
many0(preceded(multispace0, parse_single_filter))(input)
}
let parse_filter_expression = |input: &str| -> Result<NotificationFilter, String> {
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(())
}
}