mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-16 00:10:38 +08:00
bin/inbox: Improve item loading and filtering
This commit is contained in:
@@ -1,3 +1,2 @@
|
||||
pub mod inbox;
|
||||
pub mod issue;
|
||||
pub mod patch;
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
¬if,
|
||||
)
|
||||
.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,
|
||||
¬if,
|
||||
)
|
||||
})
|
||||
.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,
|
||||
¬if,
|
||||
)
|
||||
})
|
||||
.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
@@ -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
@@ -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 ¬ification.kind {
|
||||
NotificationKind::Branch { name } => {
|
||||
let (head, message) = if let Some(head) = notification.update.new() {
|
||||
let message = repo.commit(head)?.summary().unwrap_or_default().to_owned();
|
||||
(Some(head), message)
|
||||
} else {
|
||||
(None, String::new())
|
||||
};
|
||||
let status = match notification
|
||||
.update
|
||||
.new()
|
||||
.map(|oid| repo.is_ancestor_of(oid, head.unwrap()))
|
||||
.transpose()
|
||||
{
|
||||
Ok(Some(true)) => "merged",
|
||||
Ok(Some(false)) | Ok(None) => match notification.update {
|
||||
RefUpdate::Updated { .. } => "updated",
|
||||
RefUpdate::Created { .. } => "created",
|
||||
RefUpdate::Deleted { .. } => "deleted",
|
||||
RefUpdate::Skipped { .. } => "skipped",
|
||||
},
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
.to_owned();
|
||||
|
||||
Ok(Some(NotificationKindItem::Branch {
|
||||
name: name.to_string(),
|
||||
summary: message,
|
||||
status: status.to_string(),
|
||||
id: head.map(ObjectId::from),
|
||||
}))
|
||||
}
|
||||
NotificationKind::Cob { typed_id } => {
|
||||
let TypedId { id, .. } = typed_id;
|
||||
let (category, summary, state) = if typed_id.is_issue() {
|
||||
let Some(issue) = issues.get(id)? else {
|
||||
// Issue could have been deleted after notification was created.
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
"issue".to_string(),
|
||||
Title::new(issue.title())?,
|
||||
issue.state().to_string(),
|
||||
)
|
||||
} else if typed_id.is_patch() {
|
||||
let Some(patch) = patches.get(id)? else {
|
||||
// Patch could have been deleted after notification was created.
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
"patch".to_string(),
|
||||
Title::new(patch.title())?,
|
||||
patch.state().to_string(),
|
||||
)
|
||||
} else if typed_id.is_identity() {
|
||||
let Ok(identity) = Identity::get(id, repo) else {
|
||||
log::error!(
|
||||
target: "cli",
|
||||
"Error retrieving identity {id} for notification {}", notification.id
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(rev) = notification
|
||||
.update
|
||||
.new()
|
||||
.and_then(|id| identity.revision(&id))
|
||||
else {
|
||||
log::error!(
|
||||
target: "cli",
|
||||
"Error retrieving identity revision for notification {}", notification.id
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
(String::from("id"), rev.title.clone(), rev.state.to_string())
|
||||
} else {
|
||||
(
|
||||
typed_id.type_name.to_string(),
|
||||
Title::new("")?,
|
||||
"".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(Some(NotificationKindItem::Cob {
|
||||
type_name: category.to_string(),
|
||||
summary: summary.to_string(),
|
||||
status: state.to_string(),
|
||||
id: Some(*id),
|
||||
}))
|
||||
}
|
||||
NotificationKind::Unknown { refname } => Ok(Some(NotificationKindItem::Unknown {
|
||||
refname: refname.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NotificationItem {
|
||||
/// Unique notification ID.
|
||||
pub id: NotificationId,
|
||||
/// The project this belongs to.
|
||||
pub project: String,
|
||||
/// Mark this notification as seen.
|
||||
pub seen: bool,
|
||||
/// Wrapped notification kind.
|
||||
pub kind: NotificationKindItem,
|
||||
/// The author
|
||||
pub author: AuthorItem,
|
||||
/// Time the update has happened.
|
||||
pub timestamp: Timestamp,
|
||||
}
|
||||
|
||||
impl NotificationItem {
|
||||
pub fn new(
|
||||
profile: &Profile,
|
||||
repo: &Repository,
|
||||
notification: &Notification,
|
||||
) -> Result<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!(¬if.kind, NotificationKindItem::Cob {
|
||||
type_name,
|
||||
summary: _,
|
||||
status: _,
|
||||
id: _,
|
||||
} if type_name == "patch"),
|
||||
Some(NotificationType::Issue) => matches!(¬if.kind, NotificationKindItem::Cob {
|
||||
type_name,
|
||||
summary: _,
|
||||
status: _,
|
||||
id: _,
|
||||
} if type_name == "issue"),
|
||||
Some(NotificationType::Branch) => {
|
||||
matches!(notif.kind, NotificationKindItem::Branch { .. })
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
let matches_authors = if !self.authors.is_empty() {
|
||||
{
|
||||
self.authors
|
||||
.iter()
|
||||
.any(|other| notif.author.nid == Some(**other))
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
let matches_search = match &self.search {
|
||||
Some(search) => {
|
||||
let summary = match ¬if.kind {
|
||||
NotificationKindItem::Cob {
|
||||
type_name: _,
|
||||
summary,
|
||||
status: _,
|
||||
id: _,
|
||||
} => summary,
|
||||
NotificationKindItem::Branch {
|
||||
name: _,
|
||||
summary,
|
||||
status: _,
|
||||
id: _,
|
||||
} => summary,
|
||||
NotificationKindItem::Unknown { refname: _ } => "",
|
||||
};
|
||||
match matcher.fuzzy_match(summary, search) {
|
||||
Some(score) => score == 0 || score > 60,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
matches_state && matches_type && matches_authors && matches_search
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for NotificationItemFilter {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<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();
|
||||
|
||||
@@ -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 ¬ification.kind {
|
||||
node::notifications::NotificationKind::Branch { name } => {
|
||||
let (head, message) = if let Some(head) = notification.update.new() {
|
||||
let message = repo.commit(head)?.summary().unwrap_or_default().to_owned();
|
||||
(Some(head), message)
|
||||
} else {
|
||||
(None, String::new())
|
||||
};
|
||||
let status = match notification
|
||||
.update
|
||||
.new()
|
||||
.map(|oid| repo.is_ancestor_of(oid, head.unwrap()))
|
||||
.transpose()
|
||||
{
|
||||
Ok(Some(true)) => "merged",
|
||||
Ok(Some(false)) | Ok(None) => match notification.update {
|
||||
RefUpdate::Updated { .. } => "updated",
|
||||
RefUpdate::Created { .. } => "created",
|
||||
RefUpdate::Deleted { .. } => "deleted",
|
||||
RefUpdate::Skipped { .. } => "skipped",
|
||||
},
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
.to_owned();
|
||||
|
||||
Ok(Some(NotificationKind::Branch {
|
||||
name: Some(name.to_string()),
|
||||
summary: Some(message),
|
||||
status: Some(status.to_string()),
|
||||
_id: head.map(ObjectId::from),
|
||||
}))
|
||||
}
|
||||
node::notifications::NotificationKind::Cob { typed_id } => {
|
||||
let TypedId { id, type_name } = typed_id;
|
||||
let (type_name, summary, state) = if typed_id.is_issue() {
|
||||
let Some(issue) = issues.get(id)? else {
|
||||
// Issue could have been deleted after notification was created.
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
type_name,
|
||||
issue.title().to_string(),
|
||||
issue.state().to_string(),
|
||||
)
|
||||
} else if typed_id.is_patch() {
|
||||
let Some(patch) = patches.get(id)? else {
|
||||
// Patch could have been deleted after notification was created.
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
type_name,
|
||||
patch.title().to_string(),
|
||||
patch.state().to_string(),
|
||||
)
|
||||
} else if typed_id.is_identity() {
|
||||
let Ok(identity) = Identity::get(id, repo) else {
|
||||
log::error!(
|
||||
target: "items",
|
||||
"Error retrieving identity {id} for notification {}", notification.id
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(rev) = notification
|
||||
.update
|
||||
.new()
|
||||
.and_then(|id| identity.revision(&id))
|
||||
else {
|
||||
log::error!(
|
||||
target: "items",
|
||||
"Error retrieving identity revision for notification {}", notification.id
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
(type_name, rev.title.to_string(), rev.state.to_string())
|
||||
} else {
|
||||
(type_name, "".to_string(), "".to_string())
|
||||
};
|
||||
|
||||
Ok(Some(NotificationKind::Cob {
|
||||
type_name: Some(type_name.clone()),
|
||||
summary: Some(summary.to_string()),
|
||||
status: Some(state.to_string()),
|
||||
id: Some(*id),
|
||||
}))
|
||||
}
|
||||
node::notifications::NotificationKind::Unknown { refname } => {
|
||||
Ok(Some(NotificationKind::Unknown {
|
||||
refname: Some(refname.to_string()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Notification {
|
||||
/// Unique notification ID.
|
||||
pub id: node::notifications::NotificationId,
|
||||
/// The project this belongs to.
|
||||
pub project: String,
|
||||
/// Mark this notification as seen.
|
||||
pub seen: bool,
|
||||
/// Wrapped notification kind.
|
||||
pub kind: NotificationKind,
|
||||
/// The author
|
||||
pub author: AuthorItem,
|
||||
/// Time the update has happened.
|
||||
pub timestamp: Timestamp,
|
||||
}
|
||||
|
||||
impl Notification {
|
||||
pub fn new(
|
||||
profile: &Profile,
|
||||
project: &Project,
|
||||
repo: &Repository,
|
||||
notification: &node::notifications::Notification,
|
||||
) -> Result<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) => ¬if.kind == kind,
|
||||
NotificationKindFilter::Or(kinds) => {
|
||||
kinds.iter().any(|kind| ¬if.kind == kind)
|
||||
}
|
||||
},
|
||||
NotificationFilter::Author(author_filter) => match author_filter {
|
||||
DidFilter::Single(author) => notif.author.nid == Some(**author),
|
||||
DidFilter::Or(authors) => authors
|
||||
.iter()
|
||||
.any(|other| notif.author.nid == Some(**other)),
|
||||
},
|
||||
NotificationFilter::Search(search) => {
|
||||
let summary = match ¬if.kind {
|
||||
NotificationKind::Cob {
|
||||
type_name: _,
|
||||
summary,
|
||||
status: _,
|
||||
id: _,
|
||||
} => summary.clone().unwrap_or_default(),
|
||||
NotificationKind::Branch {
|
||||
name: _,
|
||||
summary,
|
||||
status: _,
|
||||
_id: _,
|
||||
} => summary.clone().unwrap_or_default(),
|
||||
NotificationKind::Unknown { refname } => {
|
||||
refname.clone().unwrap_or_default()
|
||||
}
|
||||
};
|
||||
match matcher.fuzzy_match(&summary, search) {
|
||||
Some(score) => score == 0 || score > 60,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
NotificationFilter::And(filters) => filters.iter().all(|f| f.matches(notif)),
|
||||
NotificationFilter::Empty => true,
|
||||
NotificationFilter::Invalid => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for NotificationFilter {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(filter_exp: &str) -> Result<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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user