Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
all: Implement per-repo listing
Merged did:key:z6MkswQE...2C1V opened 2 years ago

issue: Add repo option

patch: Add repo option

inbox: Implement listing all / per-repo

10 files changed +307 -105 c328030f f5d7d0fd
modified bin/commands/inbox.rs
@@ -18,6 +18,8 @@ use tui::common::cob::inbox::{self};
use crate::terminal;
use crate::terminal::args::{Args, Error, Help};

+
use self::common::{Mode, RepositoryMode, SelectionMode};
+

pub const HELP: Help = Help {
    name: "inbox",
    description: "Terminal interfaces for notifications",
@@ -55,7 +57,7 @@ pub enum OperationName {

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SelectOptions {
-
    mode: common::Mode,
+
    mode: Mode,
    filter: inbox::Filter,
    sort_by: inbox::SortBy,
}
@@ -66,6 +68,7 @@ impl Args for Options {

        let mut parser = lexopt::Parser::from_args(args);
        let mut op: Option<OperationName> = None;
+
        let mut repository_mode = None;
        let mut reverse = None;
        let mut field = None;
        let mut select_opts = SelectOptions::default();
@@ -81,11 +84,12 @@ impl Args for Options {
                    let val = parser.value()?;
                    let val = val.to_str().unwrap_or_default();

-
                    select_opts.mode = match val {
-
                        "operation" => common::Mode::Operation,
-
                        "id" => common::Mode::Id,
+
                    let selection_mode = match val {
+
                        "operation" => SelectionMode::Operation,
+
                        "id" => SelectionMode::Id,
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
                    };
+
                    select_opts.mode = select_opts.mode.with_selection(selection_mode)
                }

                Long("reverse") | Short('r') => {
@@ -101,6 +105,16 @@ impl Args for Options {
                    }
                }

+
                Long("repo") if repository_mode.is_none() && op.is_some() => {
+
                    let val = parser.value()?;
+
                    let repo = terminal::args::rid(&val)?;
+

+
                    repository_mode = Some(RepositoryMode::ByRepo((repo, None)));
+
                }
+
                Long("all") | Short('a') if repository_mode.is_none() => {
+
                    repository_mode = Some(RepositoryMode::All);
+
                }
+

                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
                    "select" => op = Some(OperationName::Select),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
@@ -109,6 +123,9 @@ impl Args for Options {
            }
        }

+
        select_opts.mode = select_opts
+
            .mode
+
            .with_repository(repository_mode.unwrap_or_default());
        select_opts.sort_by = if let Some(field) = field {
            inbox::SortBy {
                field,
modified bin/commands/inbox/common.rs
@@ -1,10 +1,46 @@
+
use radicle::identity::RepoId;
+

/// The application's subject. It tells the application
/// which widgets to render and which output to produce.
///
/// Depends on CLI arguments given by the user.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub enum Mode {
+
pub enum SelectionMode {
    Id,
    #[default]
    Operation,
}
+

+
#[derive(Clone, Default, Debug, PartialEq, Eq)]
+
pub enum RepositoryMode {
+
    #[default]
+
    Contextual,
+
    All,
+
    ByRepo((RepoId, Option<String>)),
+
}
+

+
#[derive(Clone, Default, Debug, PartialEq, Eq)]
+
pub struct Mode {
+
    selection: SelectionMode,
+
    repository: RepositoryMode,
+
}
+

+
impl Mode {
+
    pub fn with_selection(mut self, selection: SelectionMode) -> Self {
+
        self.selection = selection;
+
        self
+
    }
+

+
    pub fn with_repository(mut self, repository: RepositoryMode) -> Self {
+
        self.repository = repository;
+
        self
+
    }
+

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

+
    pub fn repository(&self) -> &RepositoryMode {
+
        &self.repository
+
    }
+
}
modified bin/commands/inbox/flux/select.rs
@@ -6,6 +6,7 @@ use anyhow::Result;
use radicle::identity::Project;
use radicle::node::notifications::NotificationId;
use radicle::storage::ReadRepository;
+
use radicle::storage::ReadStorage;

use radicle::storage::git::Repository;
use radicle::Profile;
@@ -20,14 +21,14 @@ use tui::Exit;

use ui::ListPage;

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

type Selection = tui::Selection<NotificationId>;

pub struct Context {
    pub profile: Profile,
    pub repository: Repository,
-
    pub mode: common::Mode,
+
    pub mode: Mode,
    pub filter: inbox::Filter,
    pub sort_by: inbox::SortBy,
}
@@ -40,7 +41,7 @@ pub struct App {
pub struct InboxState {
    notifications: Vec<NotificationItem>,
    selected: Option<NotificationItem>,
-
    mode: common::Mode,
+
    mode: Mode,
    project: Project,
}

@@ -51,33 +52,89 @@ impl TryFrom<&Context> for InboxState {
        let doc = context.repository.identity_doc()?;
        let project = doc.project()?;

-
        let notifications = inbox::all(&context.repository, &context.profile)?;
-
        let mut items = vec![];
+
        let mut notifications = match &context.mode.repository() {
+
            RepositoryMode::All => {
+
                let mut repos = context.profile.storage.repositories()?;
+
                repos.sort_by_key(|r| r.rid);

-
        // Convert into UI items
-
        for notif in &notifications {
-
            if let Ok(notif) =
-
                NotificationItem::try_from((&context.profile, &context.repository, notif))
-
            {
-
                items.push(notif);
+
                let mut notifs = vec![];
+
                for repo in repos {
+
                    let repo = context.profile.storage.repository(repo.rid)?;
+

+
                    let items = inbox::all(&repo, &context.profile)?
+
                        .iter()
+
                        .map(|notif| NotificationItem::try_from((&context.profile, &repo, notif)))
+
                        .filter_map(|item| item.ok())
+
                        .collect::<Vec<_>>();
+

+
                    notifs.extend(items);
+
                }
+

+
                notifs
            }
-
        }
+
            RepositoryMode::Contextual => {
+
                let notifs = inbox::all(&context.repository, &context.profile)?;
+

+
                notifs
+
                    .iter()
+
                    .map(|notif| {
+
                        NotificationItem::try_from((&context.profile, &context.repository, notif))
+
                    })
+
                    .filter_map(|item| item.ok())
+
                    .collect::<Vec<_>>()
+
            }
+
            RepositoryMode::ByRepo((rid, _)) => {
+
                let repo = context.profile.storage.repository(*rid)?;
+
                let notifs = inbox::all(&repo, &context.profile)?;
+

+
                notifs
+
                    .iter()
+
                    .map(|notif| NotificationItem::try_from((&context.profile, &repo, notif)))
+
                    .filter_map(|item| item.ok())
+
                    .collect::<Vec<_>>()
+
            }
+
        };
+

+
        // Set project name
+
        let mode = match &context.mode.repository() {
+
            RepositoryMode::ByRepo((rid, _)) => {
+
                let project = context
+
                    .profile
+
                    .storage
+
                    .repository(*rid)?
+
                    .identity_doc()?
+
                    .project()?;
+
                let name = project.name().to_string();
+

+
                context
+
                    .mode
+
                    .clone()
+
                    .with_repository(RepositoryMode::ByRepo((*rid, Some(name))))
+
            }
+
            _ => context.mode.clone(),
+
        };

        // Apply sorting
        match context.sort_by.field {
-
            "timestamp" => items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
-
            "id" => items.sort_by(|a, b| a.id.cmp(&b.id)),
+
            "timestamp" => notifications.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
+
            "id" => notifications.sort_by(|a, b| a.id.cmp(&b.id)),
            _ => {}
        }
        if context.sort_by.reverse {
-
            items.reverse();
+
            notifications.reverse();
        }
-
        let selected = items.first().cloned();
+

+
        // Sort by project if all notifications are shown
+
        if let RepositoryMode::All = mode.repository() {
+
            notifications.sort_by(|a, b| a.project.cmp(&b.project));
+
        }
+

+
        let selected = notifications.first().cloned();

        Ok(Self {
-
            notifications: items,
+
            notifications,
            selected,
-
            mode: context.mode.clone(),
+
            mode: mode.clone(),
            project,
        })
    }
modified bin/commands/inbox/flux/select/ui.rs
@@ -22,7 +22,8 @@ use tui::flux::ui::widget::{
};
use tui::Selection;

-
use super::common::Mode;
+
use crate::tui_inbox::common::{Mode, RepositoryMode, SelectionMode};
+

use super::{Action, InboxState};

pub struct ListPageProps {
@@ -87,11 +88,11 @@ impl Widget<InboxState, Action> for ListPage {
            }
            Key::Char('\n') => {
                if let Some(selected) = &self.props.selected {
-
                    let selection = match self.props.mode {
-
                        Mode::Operation => Selection::default()
+
                    let selection = match self.props.mode.selection() {
+
                        SelectionMode::Operation => Selection::default()
                            .with_operation("show".to_string())
                            .with_id(selected.id),
-
                        Mode::Id => Selection::default().with_id(selected.id),
+
                        SelectionMode::Id => Selection::default().with_id(selected.id),
                    };
                    let _ = self.action_tx.send(Action::Exit {
                        selection: Some(selection),
@@ -124,12 +125,11 @@ impl Render<()> for ListPage {
        let area = frame.size();
        let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);

-
        let shortcuts = match self.props.mode {
-
            Mode::Id => vec![Shortcut::new("enter", "select")],
-
            Mode::Operation => vec![
-
                Shortcut::new("enter", "show"),
-
                Shortcut::new("c", "clear"),
-
            ],
+
        let shortcuts = match self.props.mode.selection() {
+
            SelectionMode::Id => vec![Shortcut::new("enter", "select")],
+
            SelectionMode::Operation => {
+
                vec![Shortcut::new("enter", "show"), Shortcut::new("c", "clear")]
+
            }
        };

        self.notifications.render::<B>(frame, layout.component, ());
@@ -146,8 +146,12 @@ impl Render<()> for ListPage {

struct NotificationsProps {
    notifications: Vec<NotificationItem>,
+
    mode: Mode,
    project: Project,
    stats: HashMap<String, usize>,
+
    cutoff: usize,
+
    cutoff_after: usize,
+
    focus: bool,
}

impl From<&InboxState> for NotificationsProps {
@@ -166,8 +170,12 @@ impl From<&InboxState> for NotificationsProps {

        Self {
            notifications: state.notifications.clone(),
+
            mode: state.mode.clone(),
            project: state.project.clone(),
            stats,
+
            cutoff: 200,
+
            cutoff_after: 5,
+
            focus: false,
        }
    }
}
@@ -250,32 +258,83 @@ impl Widget<InboxState, Action> for Notifications {
    }
}

-
impl Render<()> for Notifications {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let cutoff = 200;
-
        let cutoff_after = 8;
-
        let focus = false;
+
impl Notifications {
+
    fn render_header<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
+
        let title = match self.props.mode.repository() {
+
            RepositoryMode::Contextual => self.props.project.name().to_string(),
+
            RepositoryMode::All => "All repositories".to_string(),
+
            RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(),
+
        };

-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![
+
        self.header.render::<B>(
+
            frame,
+
            area,
+
            HeaderProps {
+
                cells: [String::from("").into(), title.into()],
+
                widths: [Constraint::Length(0), Constraint::Fill(1)],
+
                focus: self.props.focus,
+
                cutoff: self.props.cutoff,
+
                cutoff_after: self.props.cutoff_after,
+
            },
+
        );
+
    }
+

+
    fn render_table<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
+
        if let RepositoryMode::All = self.props.mode.repository() {
+
            let widths = [
+
                Constraint::Length(5),
                Constraint::Length(3),
-
                Constraint::Min(1),
+
                Constraint::Length(15),
+
                Constraint::Length(25),
+
                Constraint::Fill(1),
+
                Constraint::Length(8),
+
                Constraint::Length(10),
+
                Constraint::Length(15),
+
                Constraint::Length(18),
+
            ];
+

+
            self.table.render::<B>(
+
                frame,
+
                area,
+
                TableProps {
+
                    items: self.props.notifications.to_vec(),
+
                    has_header: true,
+
                    has_footer: true,
+
                    widths,
+
                    focus: self.props.focus,
+
                    cutoff: self.props.cutoff,
+
                    cutoff_after: self.props.cutoff_after.saturating_add(1),
+
                },
+
            );
+
        } else {
+
            let widths = [
+
                Constraint::Length(5),
                Constraint::Length(3),
-
            ])
-
            .split(area);
-

-
        let widths = [
-
            Constraint::Length(5),
-
            Constraint::Length(3),
-
            Constraint::Length(20),
-
            Constraint::Fill(1),
-
            Constraint::Length(8),
-
            Constraint::Length(10),
-
            Constraint::Length(15),
-
            Constraint::Length(18),
-
        ];
+
                Constraint::Length(25),
+
                Constraint::Fill(1),
+
                Constraint::Length(8),
+
                Constraint::Length(10),
+
                Constraint::Length(15),
+
                Constraint::Length(18),
+
            ];
+

+
            self.table.render::<B>(
+
                frame,
+
                area,
+
                TableProps {
+
                    items: self.props.notifications.to_vec(),
+
                    has_header: true,
+
                    has_footer: true,
+
                    widths,
+
                    focus: self.props.focus,
+
                    cutoff: self.props.cutoff,
+
                    cutoff_after: self.props.cutoff_after,
+
                },
+
            );
+
        }
+
    }

+
    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
        let filter = Line::from([span::blank()].to_vec());
        let stats = Line::from(
            [
@@ -294,35 +353,9 @@ impl Render<()> for Notifications {
        let (step, len) = self.table.progress(self.props.notifications.len());
        let progress = span::progress(step, len, false);

-
        self.header.render::<B>(
-
            frame,
-
            layout[0],
-
            HeaderProps {
-
                cells: [String::from("").into(), self.props.project.name().into()],
-
                widths: [Constraint::Length(0), Constraint::Fill(1)],
-
                focus,
-
                cutoff,
-
                cutoff_after,
-
            },
-
        );
-

-
        self.table.render::<B>(
-
            frame,
-
            layout[1],
-
            TableProps {
-
                items: self.props.notifications.to_vec(),
-
                has_header: true,
-
                has_footer: true,
-
                focus,
-
                widths,
-
                cutoff,
-
                cutoff_after,
-
            },
-
        );
-

        self.footer.render::<B>(
            frame,
-
            layout[2],
+
            area,
            FooterProps {
                cells: [filter.into(), stats.into(), progress.clone().into()],
                widths: [
@@ -330,10 +363,27 @@ impl Render<()> for Notifications {
                    Constraint::Fill(1),
                    Constraint::Length(progress.width() as u16),
                ],
-
                focus,
-
                cutoff,
-
                cutoff_after,
+
                focus: self.props.focus,
+
                cutoff: self.props.cutoff,
+
                cutoff_after: self.props.cutoff_after,
            },
        );
    }
}
+

+
impl Render<()> for Notifications {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![
+
                Constraint::Length(3),
+
                Constraint::Min(1),
+
                Constraint::Length(3),
+
            ])
+
            .split(area);
+

+
        self.render_header::<B>(frame, layout[0]);
+
        self.render_table::<B>(frame, layout[1]);
+
        self.render_footer::<B>(frame, layout[2]);
+
    }
+
}
modified bin/commands/issue.rs
@@ -11,6 +11,7 @@ use std::ffi::OsString;

use anyhow::anyhow;

+
use radicle::identity::RepoId;
use radicle_tui as tui;

use tui::common::cob::issue::{self, State};
@@ -43,6 +44,7 @@ Other options

pub struct Options {
    op: Operation,
+
    repo: Option<RepoId>,
}

pub enum Operation {
@@ -66,6 +68,7 @@ impl Args for Options {

        let mut parser = lexopt::Parser::from_args(args);
        let mut op: Option<OperationName> = None;
+
        let mut repo = None;
        let mut select_opts = SelectOptions::default();

        while let Some(arg) = parser.next()? {
@@ -106,6 +109,13 @@ impl Args for Options {
                    }
                }

+
                Long("repo") => {
+
                    let val = parser.value()?;
+
                    let rid = terminal::args::rid(&val)?;
+

+
                    repo = Some(rid);
+
                }
+

                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
                    "select" => op = Some(OperationName::Select),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
@@ -117,7 +127,7 @@ impl Args for Options {
        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
            OperationName::Select => Operation::Select { opts: select_opts },
        };
-
        Ok((Options { op }, vec![]))
+
        Ok((Options { op, repo }, vec![]))
    }
}

@@ -162,6 +172,7 @@ pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Resu
    match options.op {
        Operation::Select { opts } => {
            let profile = terminal::profile()?;
+
            let rid = options.repo.unwrap_or(rid);
            let repository = profile.storage.repository(rid).unwrap();

            log::enable(&profile, "issue", "select")?;
modified bin/commands/patch.rs
@@ -11,6 +11,7 @@ use std::ffi::OsString;

use anyhow::anyhow;

+
use radicle::identity::RepoId;
use radicle_tui as tui;

use tui::common::cob::patch::{self, State};
@@ -52,6 +53,7 @@ Other options

pub struct Options {
    op: Operation,
+
    repo: Option<RepoId>,
}

pub enum Operation {
@@ -75,6 +77,7 @@ impl Args for Options {

        let mut parser = lexopt::Parser::from_args(args);
        let mut op: Option<OperationName> = None;
+
        let mut repo = None;
        let mut select_opts = SelectOptions::default();

        while let Some(arg) = parser.next()? {
@@ -118,6 +121,13 @@ impl Args for Options {
                        .with_author(terminal::args::did(&parser.value()?)?);
                }

+
                Long("repo") => {
+
                    let val = parser.value()?;
+
                    let rid = terminal::args::rid(&val)?;
+

+
                    repo = Some(rid);
+
                }
+

                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
                    "select" => op = Some(OperationName::Select),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
@@ -129,7 +139,7 @@ impl Args for Options {
        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
            OperationName::Select => Operation::Select { opts: select_opts },
        };
-
        Ok((Options { op }, vec![]))
+
        Ok((Options { op, repo }, vec![]))
    }
}

@@ -174,6 +184,7 @@ pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Resu
    match options.op {
        Operation::Select { opts } => {
            let profile = terminal::profile()?;
+
            let rid = options.repo.unwrap_or(rid);
            let repository = profile.storage.repository(rid).unwrap();

            log::enable(&profile, "patch", "select")?;
modified bin/terminal/args.rs
@@ -5,7 +5,7 @@ use anyhow::anyhow;

use radicle::cob::{issue, patch};
use radicle::crypto;
-
use radicle::identity::Did;
+
use radicle::identity::{Did, RepoId};

/// Git revision parameter. Supports     extended SHA-1 syntax.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -116,6 +116,11 @@ pub fn did(val: &OsString) -> anyhow::Result<Did> {
    Ok(peer)
}

+
pub fn rid(val: &OsString) -> anyhow::Result<RepoId> {
+
    let val = val.to_string_lossy();
+
    RepoId::from_str(&val).map_err(|_| anyhow!("invalid Repository ID '{}'", val))
+
}
+

#[allow(dead_code)]
pub fn issue(val: &OsString) -> anyhow::Result<issue::IssueId> {
    let val = val.to_string_lossy();
modified src/common/cob/issue.rs
@@ -132,15 +132,8 @@ impl ToString for Filter {
pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(IssueId, Issue)>> {
    let cache = profile.issues(repository)?;
    let issues = cache.list()?;
-
    
-
    let mut all = vec![];
-
    for issue in issues {
-
        if let Ok((id, issue)) = issue {
-
            all.push((id, issue))
-
        }
-
    }

-
    Ok(all)
+
    Ok(issues.flatten().collect())
}

pub fn find(profile: &Profile, repository: &Repository, id: &IssueId) -> Result<Option<Issue>> {
modified src/common/cob/patch.rs
@@ -125,14 +125,7 @@ pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(PatchId, P
    let cache = profile.patches(repository)?;
    let patches = cache.list()?;

-
    let mut all = vec![];
-
    for patch in patches {
-
        if let Ok((id, patch)) = patch {
-
            all.push((id, patch))
-
        }
-
    }
-

-
    Ok(all)
+
    Ok(patches.flatten().collect())
}

pub fn find(profile: &Profile, repository: &Repository, id: &PatchId) -> Result<Option<Patch>> {
modified src/flux/ui/cob.rs
@@ -12,8 +12,9 @@ use radicle::node::notifications::{Notification, NotificationId, NotificationKin
use radicle::node::AliasStore;
use radicle::patch::{Patch, PatchId, Patches};
use radicle::storage::git::Repository;
-
use radicle::storage::{ReadRepository, RefUpdate};
+
use radicle::storage::{ReadRepository, ReadStorage, RefUpdate};

+
use super::theme::style;
use super::widget::ToRow;
use super::{format, span};

@@ -133,6 +134,8 @@ impl TryFrom<(&Repository, &Notification)> for NotificationKindItem {
pub struct NotificationItem {
    /// Unique notification ID.
    pub id: NotificationId,
+
    /// The project this belongs to.
+
    pub project: String,
    /// Mark this notification as seen.
    pub seen: bool,
    /// Wrapped notification kind.
@@ -148,10 +151,17 @@ impl TryFrom<(&Profile, &Repository, &Notification)> for NotificationItem {

    fn try_from(value: (&Profile, &Repository, &Notification)) -> Result<Self, Self::Error> {
        let (profile, repo, notification) = value;
+
        let project = profile
+
            .storage
+
            .repository(repo.id)?
+
            .identity_doc()?
+
            .project()?;
+
        let name = project.name().to_string();
        let kind = NotificationKindItem::try_from((repo, notification))?;

        Ok(NotificationItem {
            id: notification.id,
+
            project: name,
            seen: notification.status.is_read(),
            kind,
            author: AuthorItem::new(notification.remote, profile),
@@ -227,6 +237,25 @@ impl ToRow<8> for NotificationItem {
    }
}

+
impl ToRow<9> for NotificationItem {
+
    fn to_row(&self) -> [Cell; 9] {
+
        let row: [Cell; 8] = self.to_row();
+
        let name = span::default(self.project.clone()).style(style::gray().dim());
+

+
        [
+
            row[0].clone(),
+
            row[1].clone(),
+
            name.into(),
+
            row[2].clone(),
+
            row[3].clone(),
+
            row[4].clone(),
+
            row[5].clone(),
+
            row[6].clone(),
+
            row[7].clone(),
+
        ]
+
    }
+
}
+

#[derive(Clone, Debug)]
pub struct IssueItem {
    /// Issue OID.