Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
bin/inbox: Load notifications async
✓ CI success Erik Kundt committed 5 months ago
commit f3a48147318067382b423ecd9d199a6fecb7e764
parent 0e1c18b2257c3af145609dfb77eb60ab3abfbc7d
1 passed (1 total) View logs
7 files changed +249 -179
modified bin/cob.rs
@@ -1,3 +1,2 @@
-
pub mod inbox;
pub mod issue;
pub mod patch;
deleted bin/cob/inbox.rs
@@ -1,35 +0,0 @@
-
use anyhow::Result;
-

-
use radicle::node::notifications::Notification;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

-
#[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)
-
}
modified bin/commands/inbox.rs
@@ -7,13 +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;
+
use crate::ui::items::notification::filter::{NotificationFilter, SortBy};

pub const HELP: Help = Help {
    name: "inbox",
@@ -64,7 +65,7 @@ pub enum OperationName {
pub struct ListOptions {
    mode: Mode,
    filter: NotificationFilter,
-
    sort_by: inbox::SortBy,
+
    sort_by: SortBy,
    json: bool,
}

@@ -167,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`.
@@ -207,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
modified bin/commands/inbox/list.rs
@@ -2,26 +2,26 @@
mod ui;

use std::str::FromStr;
+
use std::sync::Arc;
+
use std::sync::Mutex;

-
use anyhow::Result;
-

-
use ratatui::Viewport;
use termion::event::Key;

use ratatui::layout::Constraint;
use ratatui::style::Stylize;
use ratatui::text::Text;
+
use ratatui::Viewport;

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::task::{Process, Task};
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::{
@@ -33,9 +33,9 @@ use tui::ui::BufferedValue;
use tui::ui::Column;
use tui::{BoxedAny, Channel, Exit, PageStack};

-
use crate::cob::inbox;
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;
@@ -46,18 +46,67 @@ use super::common::{Mode, RepositoryMode};

type Selection = tui::Selection<NotificationId>;

+
#[derive(Clone, Debug, Default)]
+
struct Notifications {
+
    inner: Vec<Notification>,
+
}
+

+
impl Notifications {
+
    fn sort(&mut self, context: &Context) {
+
        // Apply sorting
+
        match context.sort_by.field {
+
            "timestamp" => self.inner.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
+
            "id" => self.inner.sort_by(|a, b| a.id.cmp(&b.id)),
+
            _ => {}
+
        }
+
        if context.sort_by.reverse {
+
            self.inner.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() {
+
            self.inner.sort_by(|a, b| a.project.cmp(&b.project));
+
        }
+
    }
+

+
    pub fn inner(&self) -> &Vec<Notification> {
+
        &self.inner
+
    }
+
}
+

+
impl From<Vec<Notification>> for Notifications {
+
    fn from(value: Vec<Notification>) -> Self {
+
        Self {
+
            inner: value.to_vec(),
+
        }
+
    }
+
}
+

#[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: NotificationFilter,
-
    pub sort_by: inbox::SortBy,
+
    pub sort_by: SortBy,
}

-
pub struct App {
-
    context: Context,
-
}
+
#[derive(Default)]
+
pub struct App {}

#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub enum AppPage {
@@ -67,7 +116,7 @@ pub enum AppPage {

#[derive(Clone, Debug)]
pub struct BrowserState {
-
    items: Vec<Notification>,
+
    items: Notifications,
    selected: Option<usize>,
    filter: NotificationFilter,
    search: BufferedValue<String>,
@@ -77,11 +126,16 @@ pub struct BrowserState {
impl BrowserState {
    pub fn notifications(&self) -> Vec<Notification> {
        self.items
+
            .inner()
            .iter()
            .filter(|patch| self.filter.matches(patch))
            .cloned()
            .collect()
    }
+

+
    pub fn sort(&mut self, context: &Context) {
+
        self.items.sort(context);
+
    }
}

#[derive(Clone, Debug)]
@@ -91,108 +145,20 @@ pub struct HelpState {

#[derive(Clone, Debug)]
pub struct State {
-
    mode: Mode,
-
    project: Project,
    pages: PageStack<AppPage>,
    browser: BrowserState,
    help: HelpState,
+
    context: Arc<Mutex<Context>>,
}

-
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()?;
-

+
impl State {
+
    fn new(context: Context) -> Result<Self, anyhow::Error> {
        let search = BufferedValue::new(context.filter.to_string());

-
        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| Notification::new(&context.profile, &project, &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| {
-
                        Notification::new(&context.profile, &project, &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| Notification::new(&context.profile, &project, &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,
+
                items: Notifications::default(),
                selected: Some(0),
                filter: context.filter.clone(),
                search,
@@ -201,6 +167,7 @@ impl TryFrom<&Context> for State {
            help: HelpState {
                text: TextViewState::default().content(help_text()),
            },
+
            context: Arc::new(Mutex::new(context)),
        })
    }
}
@@ -216,12 +183,15 @@ pub enum Message {
    OpenHelp,
    LeavePage,
    ScrollHelp { state: TextViewState },
+
    Reload,
+
    NotificationsLoaded(Vec<Notification>),
}

impl store::Update<Message> for State {
    type Return = Selection;

    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
+
        log::info!("State::update - message: {message:?}");
        match message {
            Message::Exit { selection } => Some(Exit { value: selection }),
            Message::Select { selected } => {
@@ -270,24 +240,28 @@ impl store::Update<Message> for State {
                self.help.text = state;
                None
            }
+
            Message::NotificationsLoaded(notifications) => {
+
                let context = self.context.lock().unwrap();
+
                self.browser.items = Notifications::from(notifications);
+
                self.browser.sort(&context);
+
                None
+
            }
+
            _ => None,
        }
    }
}

impl App {
-
    pub fn new(context: Context) -> Self {
-
        Self { context }
-
    }
-

-
    pub async fn run(&self) -> Result<Option<Selection>> {
+
    pub async fn run(&self, context: Context) -> anyhow::Result<Option<Selection>> {
        let channel = Channel::default();
-
        let state = State::try_from(&self.context)?;
+
        let state = State::new(context.clone())?;
        let tx = channel.tx.clone();

        let window = Window::default()
            .page(AppPage::Browse, browser_page(&state, &channel))
            .page(AppPage::Help, 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(&AppPage::Browse).clone())
@@ -295,7 +269,118 @@ impl App {
                    .into()
            });

-
        tui::rm(state, window, Viewport::Inline(20), channel).await
+
        tui::rm(
+
            state,
+
            window,
+
            Viewport::Inline(20),
+
            channel,
+
            vec![Loader::new(context)],
+
        )
+
        .await
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct Loader {
+
    context: Context,
+
}
+

+
impl Loader {
+
    fn new(context: Context) -> Self {
+
        Self { context }
+
    }
+
}
+

+
#[derive(Debug)]
+
pub struct NotificationLoader {
+
    context: Context,
+
}
+

+
impl NotificationLoader {
+
    fn new(context: Context) -> Self {
+
        NotificationLoader { context }
+
    }
+
}
+

+
impl Task for NotificationLoader {
+
    type Return = Message;
+

+
    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()?;
+

+
                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,
+
                        )
+
                    })
+
                    .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")?;
+

+
                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")?;
+

+
                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)])
+
    }
+
}
+

+
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![]),
+
        }
    }
}

@@ -309,14 +394,16 @@ fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Mes
    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 state.mode.selection() {
+
                match context.mode.selection() {
                    SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
                    SelectionMode::Operation => vec![
                        ("enter", "show"),
                        ("c", "clear"),
+
                        ("r", "reload"),
                        ("/", "search"),
                        ("?", "help"),
                    ],
@@ -341,6 +428,7 @@ fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Mes

            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,
@@ -437,7 +525,8 @@ fn help_text() -> String {

`enter`:    Select notification (if --mode id)
`enter`:    Show notification
-
`c`:        Clear notifications
+
`r`:        Reload notifications
+
`c`:        Clear notification
`/`:        Search
`?`:        Show help

modified bin/commands/inbox/list/ui.rs
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::str::FromStr;

use ratatui::Frame;
-
use tokio::sync::mpsc::UnboundedSender;
+
use tokio::sync::broadcast;

use termion::event::Key;

@@ -54,8 +54,10 @@ pub struct BrowserProps<'a> {

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(),
        };
@@ -75,7 +77,7 @@ 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,
            selected: state.browser.selected,
@@ -85,7 +87,7 @@ 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(),
@@ -109,7 +111,7 @@ pub struct Browser {
}

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| {
@@ -296,11 +298,10 @@ fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
        NotificationFilter::State(state) => Some(state),
        NotificationFilter::And(filters) => filters
            .into_iter()
-
            .map(|f| match f {
+
            .filter_map(|f| match f {
                NotificationFilter::State(state) => Some(state),
                _ => None,
            })
-
            .flatten()
            .collect::<Vec<_>>()
            .first()
            .cloned(),
modified bin/ui/items.rs
@@ -1433,7 +1433,7 @@ impl Debug for HunkItem<'_> {
    }
}

-
#[derive(Clone)]
+
#[derive(Clone, Debug)]
pub struct StatefulHunkItem<'a>(HunkItem<'a>, HunkState);

impl<'a> StatefulHunkItem<'a> {
modified bin/ui/items/notification.rs
@@ -1,6 +1,6 @@
use std::fmt;

-
use radicle::cob::{ObjectId, Timestamp, Title, TypedId};
+
use radicle::cob::{ObjectId, Timestamp, TypedId};
use radicle::identity::Identity;
use radicle::issue::Issues;
use radicle::node;
@@ -127,7 +127,7 @@ impl NotificationKind {
                    };
                    (
                        "issue".to_string(),
-
                        Title::new(issue.title())?,
+
                        issue.title().to_string(),
                        issue.state().to_string(),
                    )
                } else if typed_id.is_patch() {
@@ -137,7 +137,7 @@ impl NotificationKind {
                    };
                    (
                        "patch".to_string(),
-
                        Title::new(patch.title())?,
+
                        patch.title().to_string(),
                        patch.state().to_string(),
                    )
                } else if typed_id.is_identity() {
@@ -159,11 +159,15 @@ impl NotificationKind {
                        );
                        return Ok(None);
                    };
-
                    (String::from("id"), rev.title.clone(), rev.state.to_string())
+
                    (
+
                        String::from("id"),
+
                        rev.title.to_string(),
+
                        rev.state.to_string(),
+
                    )
                } else {
                    (
                        typed_id.type_name.to_string(),
-
                        Title::new("")?,
+
                        "".to_string(),
                        "".to_string(),
                    )
                };
@@ -327,6 +331,21 @@ pub mod filter {

    use super::{Notification, NotificationKind, NotificationState, NotificationType};

+
    #[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, Eq)]
    pub enum NotificationFilter {
        State(NotificationState),
@@ -340,10 +359,7 @@ pub mod filter {

    impl Default for NotificationFilter {
        fn default() -> Self {
-
            Self::Type(NotificationTypeFilter::Or(vec![
-
                NotificationType::Issue,
-
                NotificationType::Patch,
-
            ]))
+
            Self::Empty
        }
    }

@@ -443,9 +459,7 @@ pub mod filter {
                },
                NotificationFilter::Type(type_filter) => match type_filter {
                    NotificationTypeFilter::Single(type_name) => match_type(type_name),
-
                    NotificationTypeFilter::Or(types) => {
-
                        types.iter().any(|other| match_type(other))
-
                    }
+
                    NotificationTypeFilter::Or(types) => types.iter().any(match_type),
                },
                NotificationFilter::Author(author_filter) => match author_filter {
                    DidFilter::Single(author) => notif.author.nid == Some(**author),
@@ -589,7 +603,7 @@ pub mod filter {
                    Ok((remaining, filters)) => {
                        let remaining = remaining.trim();
                        if !remaining.is_empty() {
-
                            return Err(format!("Unparsed input remaining: '{}'", remaining));
+
                            return Err(format!("Unparsed input remaining: '{remaining}'"));
                        }

                        if filters.is_empty() {
@@ -602,7 +616,7 @@ pub mod filter {
                            Ok(NotificationFilter::And(filters))
                        }
                    }
-
                    Err(e) => Err(format!("Parse error: {}", e)),
+
                    Err(e) => Err(format!("Parse error: {e}")),
                }
            };