Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin/inbox: Port to imUI
Merged did:key:z6MkgFq6...nBGz opened 4 months ago
5 files changed +612 -745 5c59e167 9ecc9621
modified bin/commands/inbox.rs
@@ -214,7 +214,7 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
                filter: opts.filter.clone(),
                sort_by: opts.sort_by,
            };
-
            let selection = list::App::default().run(context).await?;
+
            let selection = list::Tui::new(context).run().await?;

            if opts.json {
                let selection = selection
modified bin/commands/inbox/common.rs
@@ -40,10 +40,6 @@ impl Mode {
        self
    }

-
    pub fn selection(&self) -> &SelectionMode {
-
        &self.selection
-
    }
-

    pub fn repository(&self) -> &RepositoryMode {
        &self.repository
    }
modified bin/commands/inbox/list.rs
@@ -1,11 +1,15 @@
-
#[path = "list/ui.rs"]
-
mod ui;
-

use std::str::FromStr;
-
use std::sync::Arc;
-
use std::sync::Mutex;
+
use std::sync::{Arc, Mutex};
+

+
use anyhow::Result;
+

+
use termion::event::Key;

-
use ratatui::Viewport;
+
use ratatui::layout::Constraint;
+
use ratatui::layout::Layout;
+
use ratatui::prelude::*;
+
use ratatui::text::Span;
+
use ratatui::{Frame, Viewport};

use radicle::identity::Project;
use radicle::node::notifications::NotificationId;
@@ -17,21 +21,46 @@ use radicle_tui as tui;

use tui::store;
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::{BoxedAny, Channel, Exit, PageStack};
+
use tui::ui::im;
+
use tui::ui::im::widget::{PanesState, TableState, TextEditState, TextViewState, Window};
+
use tui::ui::im::{Borders, Show};
+
use tui::ui::{BufferedValue, Column};
+
use tui::{Channel, Exit};

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::filter::{NotificationFilter, SortBy};
use crate::ui::items::notification::Notification;

-
use super::common::{Mode, RepositoryMode};
+
use super::common::{InboxOperation, Mode, RepositoryMode};

type Selection = tui::Selection<NotificationId>;

+
const HELP: &str = 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 kind=cob bugfix
+
            kind=(cob:xyz.radicle.issue or cob:xyz.radicle.issue)
+
            state=unseen author=(did:key:... or did:key:...)"#;
+

#[derive(Clone, Debug)]
pub struct Context {
    pub profile: Profile,
@@ -42,84 +71,586 @@ pub struct Context {
    pub sort_by: SortBy,
}

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

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

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

-
        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()
-
            });
+
        let state = App::try_from(&self.context)?;

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

#[derive(Clone, Debug)]
+
pub enum Change {
+
    Page {
+
        page: Page,
+
    },
+
    MainGroup {
+
        state: PanesState,
+
    },
+
    Patches {
+
        state: TableState,
+
    },
+
    Search {
+
        search: BufferedValue<TextEditState>,
+
    },
+
    Help {
+
        state: TextViewState,
+
    },
+
}
+

+
#[derive(Clone, Debug)]
pub enum Message {
-
    Exit { selection: Option<Selection> },
-
    Select { selected: Option<usize> },
-
    OpenSearch,
-
    UpdateSearch { value: String },
-
    ApplySearch,
-
    CloseSearch,
-
    OpenHelp,
-
    LeavePage,
-
    ScrollHelp { state: TextViewState },
+
    Initialize,
+
    Changed(Change),
+
    ShowSearch,
+
    HideSearch { apply: bool },
    Reload,
-
    NotificationsLoaded(Vec<Notification>),
+
    Loaded(Vec<Notification>),
+
    Exit { operation: Option<InboxOperation> },
+
    Quit,
}

#[derive(Clone, Debug)]
-
pub struct BrowserState {
-
    items: Arc<Mutex<Vec<Notification>>>,
-
    selected: Option<usize>,
-
    filter: NotificationFilter,
-
    search: BufferedValue<String>,
+
pub enum Page {
+
    Main,
+
    Help,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct AppState {
+
    page: Page,
+
    main_group: PanesState,
+
    patches: TableState,
+
    search: BufferedValue<TextEditState>,
    show_search: bool,
-
    is_loading: bool,
+
    help: TextViewState,
+
    filter: NotificationFilter,
+
    loading: bool,
+
    initialized: bool,
}

-
impl BrowserState {
-
    fn items(&self) -> Vec<Notification> {
-
        let items = self.items.lock().unwrap();
-
        items
+
#[derive(Clone, Debug)]
+
pub struct App {
+
    context: Arc<Mutex<Context>>,
+
    notifications: Arc<Mutex<Vec<Notification>>>,
+
    state: AppState,
+
}
+

+
impl TryFrom<&Context> for App {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let search = {
+
            let raw = context.filter.to_string();
+
            raw.trim().to_string()
+
        };
+
        let filter = NotificationFilter::from_str(&context.filter.to_string())
+
            .unwrap_or(NotificationFilter::Invalid);
+

+
        Ok(App {
+
            context: Arc::new(Mutex::new(context.clone())),
+
            notifications: Arc::new(Mutex::new(vec![])),
+
            state: AppState {
+
                page: Page::Main,
+
                main_group: PanesState::new(3, Some(0)),
+
                patches: TableState::new(Some(0)),
+
                search: BufferedValue::new(TextEditState {
+
                    text: search.clone(),
+
                    cursor: search.len(),
+
                }),
+
                show_search: false,
+
                help: TextViewState::new(Position::default()),
+
                filter,
+
                loading: false,
+
                initialized: false,
+
            },
+
        })
+
    }
+
}
+

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

+
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
+
        match message {
+
            Message::Initialize => {
+
                self.state.loading = true;
+
                self.state.initialized = true;
+
                None
+
            }
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => self.selected_notification().map(|issue| Exit {
+
                value: Some(Selection {
+
                    operation: operation.map(|op| op.to_string()),
+
                    ids: vec![issue.id],
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::ShowSearch => {
+
                self.state.main_group = PanesState::new(3, None);
+
                self.state.show_search = true;
+
                None
+
            }
+
            Message::HideSearch { apply } => {
+
                self.state.main_group = PanesState::new(3, Some(0));
+
                self.state.show_search = false;
+

+
                if apply {
+
                    self.state.search.apply();
+
                } else {
+
                    self.state.search.reset();
+
                }
+

+
                self.state.filter = NotificationFilter::from_str(&self.state.search.read().text)
+
                    .unwrap_or(NotificationFilter::Invalid);
+

+
                None
+
            }
+
            Message::Reload => {
+
                self.state.loading = true;
+
                None
+
            }
+
            Message::Loaded(notifications) => {
+
                self.apply_notifications(notifications);
+
                self.apply_sorting();
+
                self.state.loading = false;
+
                None
+
            }
+
            Message::Changed(changed) => match changed {
+
                Change::Page { page } => {
+
                    self.state.page = page;
+
                    None
+
                }
+
                Change::MainGroup { state } => {
+
                    self.state.main_group = state;
+
                    None
+
                }
+
                Change::Patches { state } => {
+
                    self.state.patches = state;
+
                    None
+
                }
+
                Change::Search { search } => {
+
                    self.state.search = search;
+
                    self.state.filter =
+
                        NotificationFilter::from_str(&self.state.search.read().text)
+
                            .unwrap_or(NotificationFilter::Invalid);
+
                    self.state.patches.select_first();
+
                    None
+
                }
+
                Change::Help { state } => {
+
                    self.state.help = state;
+
                    None
+
                }
+
            },
+
        }
+
    }
+
}
+

+
impl Show<Message> for App {
+
    fn show(&self, ctx: &im::Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, |ui| {
+
            // Initialize
+
            if !self.state.initialized {
+
                ui.send_message(Message::Initialize);
+
            }
+

+
            match self.state.page {
+
                Page::Main => {
+
                    let show_search = self.state.show_search;
+
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
+

+
                    ui.panes(
+
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
+
                        &mut page_focus,
+
                        |ui| {
+
                            let mut group_focus = self.state.main_group.focus();
+

+
                            let group = ui.panes(
+
                                im::Layout::Expandable3 { left_only: true },
+
                                &mut group_focus,
+
                                |ui| {
+
                                    self.show_browser(frame, ui);
+
                                },
+
                            );
+
                            if group.response.changed {
+
                                ui.send_message(Message::Changed(Change::MainGroup {
+
                                    state: PanesState::new(3, group_focus),
+
                                }));
+
                            }
+

+
                            if show_search {
+
                                self.show_browser_search(frame, ui);
+
                            } else if let Some(0) = group_focus {
+
                                self.show_browser_footer(frame, ui);
+
                            }
+
                        },
+
                    );
+
                }
+

+
                Page::Help => {
+
                    let layout = Layout::vertical([
+
                        Constraint::Length(3),
+
                        Constraint::Fill(1),
+
                        Constraint::Length(1),
+
                        Constraint::Length(1),
+
                    ]);
+

+
                    ui.composite(layout, 1, |ui| {
+
                        self.show_help_text(frame, ui);
+
                        self.show_help_context(frame, ui);
+

+
                        ui.shortcuts(frame, &[("?", "close")], '∙');
+
                    });
+

+
                    if ui.input_global(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page { page: Page::Main }));
+
                    }
+
                    if ui.input_global(|key| key == Key::Char('q')) {
+
                        ui.send_message(Message::Quit);
+
                    }
+
                }
+
            }
+
            if ui.input_global(|key| key == Key::Ctrl('c')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
+
}
+

+
impl App {
+
    pub fn show_browser(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let context = self.context.lock().unwrap();
+
        let notifs = self.notifications.lock().unwrap();
+
        let notifs = notifs
            .iter()
-
            .filter(|n| self.filter.matches(n))
+
            .filter(|notif| self.state.filter.matches(notif))
            .cloned()
-
            .collect()
+
            .collect::<Vec<_>>();
+
        let mut selected = self.state.patches.selected();
+

+
        let header = [
+
            Column::new(Span::raw(" ● ").bold(), Constraint::Length(3)),
+
            Column::new(Span::raw("ID").bold(), Constraint::Length(8)).hide_medium(),
+
            Column::new(Span::raw("Summary").bold(), Constraint::Fill(1)),
+
            Column::new(Span::raw("Repository").bold(), Constraint::Length(16))
+
                .skip(*context.mode.repository() != RepositoryMode::All),
+
            Column::new(Span::raw("OID").bold(), Constraint::Length(8)).hide_medium(),
+
            Column::new(Span::raw("Kind").bold(), Constraint::Length(20)).hide_small(),
+
            Column::new(Span::raw("Change").bold(), Constraint::Length(8)).hide_small(),
+
            Column::new(Span::raw("Author").bold(), Constraint::Length(16)).hide_medium(),
+
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)),
+
        ];
+

+
        let table = ui.headered_table(
+
            frame,
+
            &mut selected,
+
            &notifs,
+
            header.clone(),
+
            header,
+
            Some("No notifications found".into()),
+
        );
+
        if table.changed {
+
            ui.send_message(Message::Changed(Change::Patches {
+
                state: TableState::new(selected),
+
            }));
+
        }
+

+
        if self.state.loading {
+
            self.show_loading_popup(frame, ui);
+
        }
+

+
        // TODO(erikli): Should only work if table has focus
+
        if ui.input_global(|key| key == Key::Char('/')) {
+
            ui.send_message(Message::ShowSearch);
+
        }
+
    }
+

+
    fn show_browser_footer(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        ui.layout(Layout::vertical([1, 1]), None, |ui| {
+
            self.show_browser_context(frame, ui);
+
            self.show_browser_shortcuts(frame, ui);
+
        });
+
        if ui.input_global(|key| key == Key::Char('q')) {
+
            ui.send_message(Message::Quit);
+
        }
+
        if ui.input_global(|key| key == Key::Char('?')) {
+
            ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
+
        }
+
        if ui.input_global(|key| key == Key::Char('\n')) {
+
            ui.send_message(Message::Exit {
+
                operation: Some(InboxOperation::Show),
+
            });
+
        }
+
        if ui.input_global(|key| key == Key::Char('c')) {
+
            ui.send_message(Message::Exit {
+
                operation: Some(InboxOperation::Clear),
+
            });
+
        }
+
        if ui.input_global(|key| key == Key::Char('r')) {
+
            ui.send_message(Message::Reload);
+
        }
+
    }
+

+
    pub fn show_browser_search(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let (mut search_text, mut search_cursor) = (
+
            self.state.search.clone().read().text,
+
            self.state.search.clone().read().cursor,
+
        );
+
        let mut search = self.state.search.clone();
+

+
        let text_edit = ui.text_edit_labeled_singleline(
+
            frame,
+
            &mut search_text,
+
            &mut search_cursor,
+
            "Search".to_string(),
+
            Some(Borders::Spacer { top: 0, left: 0 }),
+
        );
+

+
        if text_edit.changed {
+
            search.write(TextEditState {
+
                text: search_text,
+
                cursor: search_cursor,
+
            });
+
            ui.send_message(Message::Changed(Change::Search { search }));
+
        }
+

+
        if ui.input_global(|key| key == Key::Esc) {
+
            ui.send_message(Message::HideSearch { apply: false });
+
        }
+
        if ui.input_global(|key| key == Key::Char('\n')) {
+
            ui.send_message(Message::HideSearch { apply: true });
+
        }
+
    }
+

+
    fn show_browser_context(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let context = {
+
            let notifs = self.notifications.lock().unwrap();
+
            let search = self.state.search.read().text;
+
            let total_count = notifs.len();
+
            let filtered_count = notifs
+
                .iter()
+
                .filter(|patch| self.state.filter.matches(patch))
+
                .collect::<Vec<_>>()
+
                .len();
+

+
            let filtered_counts = format!(" {filtered_count}/{total_count} ");
+
            let seen_counts = notifs
+
                .iter()
+
                .fold((0, 0), |counts, notif| match notif.seen {
+
                    true => (counts.0 + 1, counts.1),
+
                    false => (counts.0, counts.1 + 1),
+
                });
+

+
            if self.state.filter.is_default() {
+
                let seen = format!(" {} ", seen_counts.0);
+
                let unseen = format!(" {} ", seen_counts.1);
+
                [
+
                    Column::new(
+
                        Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                        Constraint::Length(8),
+
                    ),
+
                    Column::new(
+
                        Span::raw(format!(" {search} "))
+
                            .into_left_aligned_line()
+
                            .style(ui.theme().bar_on_black_style),
+
                        Constraint::Fill(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw("●")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .gray()
+
                            .dim()
+
                            .bold(),
+
                        Constraint::Length(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw(seen.clone())
+
                            .style(ui.theme().bar_on_black_style)
+
                            .dim(),
+
                        Constraint::Length(seen.chars().count() as u16),
+
                    ),
+
                    Column::new(
+
                        Span::raw("●")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .cyan()
+
                            .dim()
+
                            .bold(),
+
                        Constraint::Length(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw(unseen.clone())
+
                            .style(ui.theme().bar_on_black_style)
+
                            .dim(),
+
                        Constraint::Length(unseen.chars().count() as u16),
+
                    ),
+
                    Column::new(
+
                        Span::raw(filtered_counts.clone())
+
                            .into_right_aligned_line()
+
                            .cyan()
+
                            .dim()
+
                            .reversed(),
+
                        Constraint::Length(filtered_counts.chars().count() as u16),
+
                    ),
+
                ]
+
                .to_vec()
+
            } else {
+
                [
+
                    Column::new(
+
                        Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                        Constraint::Length(8),
+
                    ),
+
                    Column::new(
+
                        Span::raw(format!(" {search} "))
+
                            .into_left_aligned_line()
+
                            .style(ui.theme().bar_on_black_style),
+
                        Constraint::Fill(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw(filtered_counts.clone())
+
                            .into_right_aligned_line()
+
                            .cyan()
+
                            .dim()
+
                            .reversed(),
+
                        Constraint::Length(filtered_counts.chars().count() as u16),
+
                    ),
+
                ]
+
                .to_vec()
+
            }
+
        };
+

+
        ui.bar(frame, context, Some(Borders::None));
+
    }
+

+
    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        ui.shortcuts(
+
            frame,
+
            &[
+
                ("enter", "show"),
+
                ("r", "reload"),
+
                ("c", "clear"),
+
                ("/", "search"),
+
                ("?", "help"),
+
            ],
+
            '∙',
+
        );
    }

-
    fn apply_filter(&mut self, filter: NotificationFilter) {
-
        self.filter = filter;
+
    fn show_loading_popup(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        ui.popup(Layout::vertical([Constraint::Min(1)]), |ui| {
+
            ui.layout(
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).margin(1),
+
                None,
+
                |ui| {
+
                    ui.label(frame, "");
+
                    ui.layout(
+
                        Layout::horizontal([Constraint::Min(1), Constraint::Length(11)]),
+
                        None,
+
                        |ui| {
+
                            ui.label(frame, "");
+
                            ui.columns(
+
                                frame,
+
                                [Column::new(
+
                                    Span::raw(" Loading ").magenta().slow_blink(),
+
                                    Constraint::Fill(1),
+
                                )]
+
                                .to_vec(),
+
                                Some(Borders::All),
+
                            );
+
                        },
+
                    );
+
                },
+
            );
+
            ui.centered_text_view(frame, "Loading".slow_blink().yellow(), None);
+
        });
+
    }
+

+
    fn show_help_text(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        ui.columns(
+
            frame,
+
            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
            Some(Borders::Top),
+
        );
+

+
        let mut cursor = self.state.help.cursor();
+
        let text_view = ui.text_view(
+
            frame,
+
            HELP.to_string(),
+
            &mut cursor,
+
            Some(Borders::BottomSides),
+
        );
+
        if text_view.changed {
+
            ui.send_message(Message::Changed(Change::Help {
+
                state: TextViewState::new(cursor),
+
            }))
+
        }
    }

+
    fn show_help_context(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        ui.bar(
+
            frame,
+
            [
+
                Column::new(
+
                    Span::raw(" ".to_string())
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    Span::raw(" ")
+
                        .into_right_aligned_line()
+
                        .cyan()
+
                        .dim()
+
                        .reversed(),
+
                    Constraint::Length(6),
+
                ),
+
            ]
+
            .to_vec(),
+
            Some(Borders::None),
+
        );
+
    }
+

+
    pub fn selected_notification(&self) -> Option<Notification> {
+
        let patches = self.notifications.lock().unwrap();
+
        match self.state.patches.selected() {
+
            Some(selected) => patches
+
                .iter()
+
                .filter(|patch| self.state.filter.matches(patch))
+
                .collect::<Vec<_>>()
+
                .get(selected)
+
                .cloned()
+
                .cloned(),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl App {
    fn apply_notifications(&mut self, notifications: Vec<Notification>) {
-
        let mut items = self.items.lock().unwrap();
+
        let mut items = self.notifications.lock().unwrap();
        *items = notifications;
    }

-
    fn apply_sorting(&mut self, context: &Context) {
-
        let mut items = self.items.lock().unwrap();
+
    fn apply_sorting(&mut self) {
+
        let mut items = self.notifications.lock().unwrap();
+
        let context = self.context.lock().unwrap();
        // Apply sorting
        match context.sort_by.field {
            "timestamp" => items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
@@ -150,120 +681,6 @@ impl BrowserState {
}

#[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 {
-
    type Return = Selection;
-

-
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
-
        match message {
-
            Message::Exit { selection } => Some(Exit { value: selection }),
-
            Message::Select { selected } => {
-
                self.browser.selected = selected;
-
                None
-
            }
-
            Message::OpenSearch => {
-
                self.browser.show_search = true;
-
                None
-
            }
-
            Message::UpdateSearch { value } => {
-
                self.browser.search.write(value);
-
                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 > items.len() {
-
                        self.browser.selected = Some(0);
-
                    }
-
                }
-

-
                None
-
            }
-
            Message::ApplySearch => {
-
                self.browser.search.apply();
-
                self.browser.show_search = false;
-
                None
-
            }
-
            Message::CloseSearch => {
-
                self.browser.search.reset();
-
                self.browser.show_search = false;
-
                self.browser.apply_filter(
-
                    NotificationFilter::from_str(&self.browser.search.read())
-
                        .unwrap_or(NotificationFilter::Invalid),
-
                );
-

-
                None
-
            }
-
            Message::OpenHelp => {
-
                self.pages.push(PageState::Help);
-
                None
-
            }
-
            Message::LeavePage => {
-
                self.pages.pop();
-
                None
-
            }
-
            Message::ScrollHelp { 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
-
            }
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug)]
pub struct Loader {
    context: Context,
}
@@ -297,12 +714,8 @@ impl Task for NotificationLoader {
                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,
-
                        )
+
                        let project = repo.project()?;
+
                        Notification::new(&self.context.profile, &repo, &project, &notif)
                    })
                    .filter_map(|notif| notif.ok())
                    .flatten()
@@ -310,6 +723,7 @@ impl Task for NotificationLoader {
            }
            RepositoryMode::Contextual => {
                let repo = self.context.profile.storage.repository(self.context.rid)?;
+
                let project = repo.project()?;
                let notifs = self.context.profile.notifications_mut()?;
                let by_repo = notifs.by_repo(&repo.id, "timestamp")?;

@@ -317,12 +731,7 @@ impl Task for NotificationLoader {
                    .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,
-
                        )
+
                        Notification::new(&self.context.profile, &repo, &project, &notif)
                    })
                    .filter_map(|notif| notif.ok())
                    .flatten()
@@ -330,6 +739,7 @@ impl Task for NotificationLoader {
            }
            RepositoryMode::ByRepo((rid, _)) => {
                let repo = self.context.profile.storage.repository(*rid)?;
+
                let project = repo.project()?;
                let notifs = self.context.profile.notifications_mut()?;
                let by_repo = notifs.by_repo(&repo.id, "timestamp")?;

@@ -337,12 +747,7 @@ impl Task for NotificationLoader {
                    .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,
-
                        )
+
                        Notification::new(&self.context.profile, &repo, &project, &notif)
                    })
                    .filter_map(|notif| notif.ok())
                    .flatten()
@@ -350,14 +755,14 @@ impl Task for NotificationLoader {
            }
        };

-
        Ok(vec![Message::NotificationsLoaded(notifications)])
+
        Ok(vec![Message::Loaded(notifications)])
    }
}

impl Process<Message> for Loader {
    async fn process(&mut self, message: Message) -> anyhow::Result<Vec<Message>> {
        match message {
-
            Message::Reload => {
+
            Message::Initialize | Message::Reload => {
                let loader = NotificationLoader::new(self.context.clone());
                let messages = tokio::spawn(async move { loader.run() }).await.unwrap()?;
                Ok(messages)
deleted bin/commands/inbox/list/ui.rs
@@ -1,539 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-

-
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::list::{Table, TableProps};
-
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, Channel, Selection};
-

-
use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
-
use crate::ui::items::notification::filter::NotificationFilter;
-
use crate::ui::items::notification::{Notification, NotificationState};
-

-
use super::{Message, State};
-

-
type AppWidget = widget::Widget<State, Message>;
-

-
#[derive(Clone, Default)]
-
pub struct BrowserProps<'a> {
-
    /// Application mode: openation and id or id only.
-
    mode: Mode,
-
    /// Table title
-
    header: String,
-
    /// Filtered notifications.
-
    notifications: Vec<Notification>,
-
    /// Current (selected) table index
-
    selected: Option<usize>,
-
    /// Notification statistics.
-
    stats: HashMap<String, usize>,
-
    /// Table columns
-
    columns: Vec<Column<'a>>,
-
    /// If search widget should be shown.
-
    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 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.items();
-

-
        // Compute statistics
-
        let mut seen = 0;
-
        let mut unseen = 0;
-
        for notification in &notifications {
-
            if notification.seen {
-
                seen += 1;
-
            } else {
-
                unseen += 1;
-
            }
-
        }
-
        let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]);
-

-
        Self {
-
            mode: context.mode.clone(),
-
            header,
-
            notifications,
-
            selected: state.browser.selected,
-
            stats,
-
            columns: [
-
                Column::new("", Constraint::Length(5)),
-
                Column::new("", Constraint::Length(3)),
-
                Column::new("", Constraint::Fill(5)),
-
                Column::new("", Constraint::Fill(1))
-
                    .skip(*context.mode.repository() != RepositoryMode::All),
-
                Column::new("", Constraint::Fill(1))
-
                    .hide_small()
-
                    .hide_medium(),
-
                Column::new("", Constraint::Length(20)),
-
                Column::new("", Constraint::Length(10)),
-
                Column::new("", Constraint::Min(12)).hide_small(),
-
                Column::new("", Constraint::Min(14)).hide_small(),
-
            ]
-
            .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: AppWidget,
-
    /// Loader widget
-
    loader: AppWidget,
-
    /// Search widget
-
    search: AppWidget,
-
}
-

-
impl Browser {
-
    pub fn new(tx: broadcast::Sender<Message>) -> Self {
-
        Self {
-
            notifications: Container::default()
-
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-
                    HeaderProps::default()
-
                        .columns(
-
                            [
-
                                Column::new("", Constraint::Length(0)),
-
                                Column::new(Text::from(props.header), Constraint::Fill(1)),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(
-
                    Table::<State, Message, Notification, 9>::default()
-
                        .to_widget(tx.clone())
-
                        .on_event(|_, s, _| {
-
                            let (selected, _) =
-
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::Select {
-
                                selected: Some(selected),
-
                            })
-
                        })
-
                        .on_update(|state| {
-
                            let props = BrowserProps::from(state);
-
                            let items = state.browser.items();
-

-
                            TableProps::default()
-
                                .columns(props.columns)
-
                                .items(items)
-
                                .selected(state.browser.selected)
-
                                .to_boxed_any()
-
                                .into()
-
                        }),
-
                )
-
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
-
                    let props = BrowserProps::from(state);
-

-
                    FooterProps::default()
-
                        .columns(browse_footer(&props))
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    ContainerProps::default()
-
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .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, _| {
-
                    Some(Message::UpdateSearch {
-
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.search.read().to_string())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        }
-
    }
-
}
-

-
impl View for Browser {
-
    type Message = Message;
-
    type State = State;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            match key {
-
                Key::Esc => {
-
                    self.search.reset();
-
                    Some(Message::CloseSearch)
-
                }
-
                Key::Char('\n') => Some(Message::ApplySearch),
-
                _ => {
-
                    self.search.handle_event(key);
-
                    None
-
                }
-
            }
-
        } else {
-
            match key {
-
                Key::Char('/') => Some(Message::OpenSearch),
-
                Key::Char('\n') => props
-
                    .selected
-
                    .and_then(|selected| props.notifications.get(selected))
-
                    .map(|notif| {
-
                        let selection = match props.mode.selection() {
-
                            SelectionMode::Operation => Selection::default()
-
                                .with_operation(InboxOperation::Show.to_string())
-
                                .with_id(notif.id),
-
                            SelectionMode::Id => Selection::default().with_id(notif.id),
-
                        };
-

-
                        Message::Exit {
-
                            selection: Some(selection),
-
                        }
-
                    }),
-
                Key::Char('c') => props
-
                    .selected
-
                    .and_then(|selected| props.notifications.get(selected))
-
                    .map(|notif| Message::Exit {
-
                        selection: Some(
-
                            Selection::default()
-
                                .with_operation(InboxOperation::Clear.to_string())
-
                                .with_id(notif.id),
-
                        ),
-
                    }),
-
                _ => {
-
                    self.notifications.handle_event(key);
-
                    None
-
                }
-
            }
-
        }
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        self.notifications.update(state);
-
        self.loader.update(state);
-
        self.search.update(state);
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .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);
-
            let [_, search_area, _] = Layout::horizontal([
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
                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 {
-
            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(),
-
        span::default(" "),
-
        span::default(&props.search.to_string()).gray().dim(),
-
    ]);
-

-
    let seen = Line::from(vec![
-
        span::positive(&props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Seen").dim(),
-
    ]);
-
    let unseen = Line::from(vec![
-
        span::positive(&props.stats.get("Unseen").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Unseen").dim(),
-
    ]);
-
    let sum = Line::from(vec![
-
        span::default("Σ ").dim(),
-
        span::default(&props.notifications.len().to_string()).dim(),
-
    ]);
-

-
    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,
-
                NotificationState::Unseen => unseen,
-
            };
-

-
            [
-
                Column::new(Text::from(search), Constraint::Fill(1)),
-
                Column::new(
-
                    Text::from(block.clone()),
-
                    Constraint::Min(block.width() as u16),
-
                ),
-
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
            ]
-
            .to_vec()
-
        }
-
        None => [
-
            Column::new(Text::from(search), Constraint::Fill(1)),
-
            Column::new(
-
                Text::from(seen.clone()),
-
                Constraint::Min(seen.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(unseen.clone()),
-
                Constraint::Min(unseen.width() as u16),
-
            ),
-
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
        ]
-
        .to_vec(),
-
    }
-
}
modified bin/ui/items/notification.rs
@@ -232,11 +232,10 @@ pub struct Notification {
impl Notification {
    pub fn new(
        profile: &Profile,
-
        project: &Project,
        repo: &Repository,
+
        project: &Project,
        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() {
@@ -245,7 +244,7 @@ impl Notification {

        Ok(Some(Notification {
            id: notification.id,
-
            project: name,
+
            project: project.name().to_string(),
            seen: notification.status.is_read(),
            kind: kind.unwrap(),
            author: AuthorItem::new(notification.remote, profile),
@@ -321,8 +320,8 @@ impl ToRow<9> for Notification {
        let timestamp = span::timestamp(&super::format::timestamp(&self.timestamp));

        [
-
            id.into(),
            seen.into(),
+
            id.into(),
            summary.into(),
            name.into(),
            kind_id.into(),
@@ -401,6 +400,12 @@ pub mod filter {
        }
    }

+
    impl NotificationFilter {
+
        pub fn is_default(&self) -> bool {
+
            *self == NotificationFilter::default()
+
        }
+
    }
+

    impl fmt::Display for NotificationFilter {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {