Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: List issues
Erik Kundt committed 2 years ago
commit 53db584b58eafab15cf5971ce4d7a411ac1b2b03
parent b3581de617fdcd64bbd12620c0218546dae3bb27
5 files changed +218 -50
modified radicle-tui/src/app/event.rs
@@ -68,13 +68,28 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<PatchBrowser> {
    }
}

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<Dashboard> {
-
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
-
        None
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<IssueBrowser> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => Some(Message::Tick),
+
            _ => None,
+
        }
    }
}

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<IssueBrowser> {
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Dashboard> {
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
        None
    }
modified radicle-tui/src/app/page.rs
@@ -65,7 +65,7 @@ impl ViewPage for HomeView {
        let navigation = widget::home::navigation(theme).to_boxed();

        let dashboard = widget::home::dashboard(theme, &context.id, &context.project).to_boxed();
-
        let issue_browser = widget::home::issues(theme).to_boxed();
+
        let issue_browser = widget::home::issues(theme, &context.id, &context.profile).to_boxed();
        let patch_browser = widget::home::patches(theme, &context.id, &context.profile).to_boxed();

        app.remount(
modified radicle-tui/src/ui/cob.rs
@@ -8,8 +8,9 @@ use radicle::storage::git::Repository;
use radicle::storage::{Oid, ReadRepository};
use radicle::Profile;

-
use radicle::cob::patch::{Patch, PatchId, State};
-
use radicle::cob::Timestamp;
+
use radicle::cob::issue::{Issue, IssueId, State as IssueState};
+
use radicle::cob::patch::{Patch, PatchId, State as PatchState};
+
use radicle::cob::{Tag, Timestamp};

use tuirealm::props::{Color, Style};
use tuirealm::tui::widgets::Cell;
@@ -38,7 +39,7 @@ pub struct PatchItem {
    /// Patch OID.
    id: PatchId,
    /// Patch state.
-
    state: State,
+
    state: PatchState,
    /// Patch title.
    title: String,
    /// Author of the latest revision.
@@ -88,17 +89,17 @@ impl TryFrom<(&Profile, &Repository, PatchId, Patch)> for PatchItem {

impl TableItem<8> for PatchItem {
    fn row(&self, theme: &Theme) -> [Cell; 8] {
-
        let (icon, color) = format_state(&self.state);
+
        let (icon, color) = format_patch_state(&self.state);
        let state = Cell::from(icon).style(Style::default().fg(color));

        let id = Cell::from(format::cob(&self.id))
-
            .style(Style::default().fg(theme.colors.browser_patch_list_id));
+
            .style(Style::default().fg(theme.colors.browser_list_id));

        let title = Cell::from(self.title.clone())
-
            .style(Style::default().fg(theme.colors.browser_patch_list_title));
+
            .style(Style::default().fg(theme.colors.browser_list_title));

        let author = Cell::from(format_author(&self.author.did, self.author.is_you))
-
            .style(Style::default().fg(theme.colors.browser_patch_list_author));
+
            .style(Style::default().fg(theme.colors.browser_list_author));

        let head = Cell::from(format::oid(self.head))
            .style(Style::default().fg(theme.colors.browser_patch_list_head));
@@ -110,24 +111,105 @@ impl TableItem<8> for PatchItem {
            .style(Style::default().fg(theme.colors.browser_patch_list_removed));

        let updated = Cell::from(format::timestamp(&self.timestamp).to_string())
-
            .style(Style::default().fg(theme.colors.browser_patch_list_timestamp));
+
            .style(Style::default().fg(theme.colors.browser_list_timestamp));

        [state, id, title, author, head, added, removed, updated]
    }
}

+
/// An issue item that can be used in tables, list or trees.
+
///
+
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
+
/// would be needed if [`Issue`] would be used directly.
+
#[derive(Clone)]
+
pub struct IssueItem {
+
    /// Issue OID.
+
    id: IssueId,
+
    /// Issue state.
+
    state: IssueState,
+
    /// Issue title.
+
    title: String,
+
    /// Issue author.
+
    author: AuthorItem,
+
    /// Issue tags.
+
    tags: Vec<Tag>,
+
    /// Issue assignees.
+
    assignees: Vec<AuthorItem>,
+
    /// Time when issue was opened.
+
    timestamp: Timestamp,
+
}
+

+
impl TryFrom<(&Profile, &Repository, IssueId, Issue)> for IssueItem {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Profile, &Repository, IssueId, Issue)) -> Result<Self, Self::Error> {
+
        let (profile, _, id, issue) = value;
+

+
        Ok(IssueItem {
+
            id,
+
            state: *issue.state(),
+
            title: issue.title().into(),
+
            author: AuthorItem {
+
                did: issue.author().id,
+
                is_you: *issue.author().id == *profile.did(),
+
            },
+
            tags: issue.tags().cloned().collect(),
+
            assignees: issue
+
                .assigned()
+
                .map(|did| AuthorItem {
+
                    did,
+
                    is_you: did == profile.did(),
+
                })
+
                .collect::<Vec<_>>(),
+
            timestamp: issue.timestamp(),
+
        })
+
    }
+
}
+

+
impl TableItem<7> for IssueItem {
+
    fn row(&self, theme: &Theme) -> [Cell; 7] {
+
        let (icon, color) = format_issue_state(&self.state);
+
        let state = Cell::from(icon).style(Style::default().fg(color));
+

+
        let id = Cell::from(format::cob(&self.id))
+
            .style(Style::default().fg(theme.colors.browser_list_id));
+

+
        let title = Cell::from(self.title.clone())
+
            .style(Style::default().fg(theme.colors.browser_list_title));
+

+
        let author = Cell::from(format_author(&self.author.did, self.author.is_you))
+
            .style(Style::default().fg(theme.colors.browser_list_author));
+

+
        let tags = Cell::from(format_tags(&self.tags))
+
            .style(Style::default().fg(theme.colors.browser_list_tags));
+

+
        let assignees = self
+
            .assignees
+
            .iter()
+
            .map(|author| (author.did, author.is_you))
+
            .collect::<Vec<_>>();
+
        let assignees = Cell::from(format_assignees(&assignees))
+
            .style(Style::default().fg(theme.colors.browser_list_author));
+

+
        let opened = Cell::from(format::timestamp(&self.timestamp).to_string())
+
            .style(Style::default().fg(theme.colors.browser_list_timestamp));
+

+
        [state, id, title, author, tags, assignees, opened]
+
    }
+
}
+

impl TableItem<1> for () {
    fn row(&self, _theme: &Theme) -> [Cell; 1] {
        [Cell::default()]
    }
}

-
pub fn format_state(state: &State) -> (String, Color) {
+
pub fn format_patch_state(state: &PatchState) -> (String, Color) {
    match state {
-
        State::Open { conflicts: _ } => (" ● ".into(), Color::Green),
-
        State::Archived => (" ● ".into(), Color::Yellow),
-
        State::Draft => (" ● ".into(), Color::Gray),
-
        State::Merged {
+
        PatchState::Open { conflicts: _ } => (" ● ".into(), Color::Green),
+
        PatchState::Archived => (" ● ".into(), Color::Yellow),
+
        PatchState::Draft => (" ● ".into(), Color::Gray),
+
        PatchState::Merged {
            revision: _,
            commit: _,
        } => (" ✔ ".into(), Color::Blue),
@@ -141,3 +223,38 @@ pub fn format_author(did: &Did, is_you: bool) -> String {
        format!("{}", format::did(did))
    }
}
+

+
pub fn format_issue_state(state: &IssueState) -> (String, Color) {
+
    match state {
+
        IssueState::Open => (" ● ".into(), Color::Green),
+
        IssueState::Closed { reason: _ } => (" ● ".into(), Color::Red),
+
    }
+
}
+

+
pub fn format_tags(tags: &[Tag]) -> String {
+
    let mut output = String::new();
+
    let mut tags = tags.iter().peekable();
+

+
    while let Some(tag) = tags.next() {
+
        output.push_str(&tag.to_string());
+

+
        if tags.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
+

+
pub fn format_assignees(assignees: &[(Did, bool)]) -> String {
+
    let mut output = String::new();
+
    let mut assignees = assignees.iter().peekable();
+

+
    while let Some((assignee, is_you)) = assignees.next() {
+
        output.push_str(&format_author(assignee, *is_you));
+

+
        if assignees.peek().is_some() {
+
            output.push(',');
+
        }
+
    }
+
    output
+
}
modified radicle-tui/src/ui/theme.rs
@@ -19,15 +19,15 @@ pub struct Colors {
    pub shortcut_short_fg: Color,
    pub shortcut_long_fg: Color,
    pub shortcutbar_divider_fg: Color,
-
    pub browser_patch_list_id: Color,
-
    pub browser_patch_list_title: Color,
-
    pub browser_patch_list_author: Color,
+
    pub browser_list_id: Color,
+
    pub browser_list_title: Color,
+
    pub browser_list_author: Color,
+
    pub browser_list_tags: Color,
+
    pub browser_list_comments: Color,
+
    pub browser_list_timestamp: Color,
    pub browser_patch_list_head: Color,
    pub browser_patch_list_added: Color,
    pub browser_patch_list_removed: Color,
-
    pub browser_patch_list_tags: Color,
-
    pub browser_patch_list_comments: Color,
-
    pub browser_patch_list_timestamp: Color,
    pub context_bg: Color,
    pub context_light_bg: Color,
    pub context_badge_bg: Color,
@@ -86,15 +86,15 @@ pub fn default_dark() -> Theme {
            shortcut_short_fg: COLOR_DEFAULT_DARK,
            shortcut_long_fg: COLOR_DEFAULT_DARKER,
            shortcutbar_divider_fg: COLOR_DEFAULT_DARKER,
-
            browser_patch_list_id: Color::Cyan,
-
            browser_patch_list_title: COLOR_DEFAULT_FG,
-
            browser_patch_list_author: Color::Gray,
+
            browser_list_id: Color::Cyan,
+
            browser_list_title: COLOR_DEFAULT_FG,
+
            browser_list_author: Color::Gray,
+
            browser_list_tags: Color::LightBlue,
+
            browser_list_comments: COLOR_DEFAULT_DARK_FG,
+
            browser_list_timestamp: COLOR_DEFAULT_DARK,
            browser_patch_list_head: Color::LightBlue,
            browser_patch_list_added: Color::Green,
            browser_patch_list_removed: Color::Red,
-
            browser_patch_list_tags: Color::Yellow,
-
            browser_patch_list_comments: COLOR_DEFAULT_DARK_FG,
-
            browser_patch_list_timestamp: COLOR_DEFAULT_DARK,
            context_bg: COLOR_DEFAULT_DARKEST,
            context_light_bg: Color::Gray,
            context_badge_bg: Color::LightRed,
modified radicle-tui/src/ui/widget/home.rs
@@ -1,3 +1,4 @@
+
use radicle::cob::issue::Issues;
use radicle::prelude::{Id, Project};
use radicle::storage::ReadStorage;
use radicle::Profile;
@@ -11,12 +12,11 @@ use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
use super::common;
use super::common::container::{LabeledContainer, Tabs};
use super::common::context::Shortcuts;
-
use super::common::label::Label;
use super::common::list::{ColumnWidth, Table};

use super::{Widget, WidgetComponent};

-
use crate::ui::cob::PatchItem;
+
use crate::ui::cob::{IssueItem, PatchItem};
use crate::ui::layout;
use crate::ui::theme::Theme;

@@ -54,23 +54,61 @@ impl WidgetComponent for Dashboard {
}

pub struct IssueBrowser {
-
    label: Widget<Label>,
+
    table: Widget<Table<IssueItem, 7>>,
    shortcuts: Widget<Shortcuts>,
}

impl IssueBrowser {
-
    pub fn new(label: Widget<Label>, shortcuts: Widget<Shortcuts>) -> Self {
-
        Self { label, shortcuts }
+
    pub fn new(theme: &Theme, profile: &Profile, id: &Id, shortcuts: Widget<Shortcuts>) -> Self {
+
        let repo = profile.storage.repository(*id).unwrap();
+
        let issues = Issues::open(&repo)
+
            .and_then(|issues| issues.all().map(|iter| iter.flatten().collect::<Vec<_>>()));
+

+
        let header = [
+
            common::label(" ● "),
+
            common::label("ID"),
+
            common::label("Title"),
+
            common::label("Author"),
+
            common::label("Tags"),
+
            common::label("Assignees"),
+
            common::label("Opened"),
+
        ];
+

+
        let widths = [
+
            ColumnWidth::Fixed(3),
+
            ColumnWidth::Fixed(7),
+
            ColumnWidth::Grow,
+
            ColumnWidth::Fixed(21),
+
            ColumnWidth::Fixed(25),
+
            ColumnWidth::Fixed(21),
+
            ColumnWidth::Fixed(18),
+
        ];
+

+
        let mut items = vec![];
+
        if let Ok(mut issues) = issues {
+
            issues.sort_by(|(_, a, _), (_, b, _)| b.timestamp().cmp(&a.timestamp()));
+
            issues.sort_by(|(_, a, _), (_, b, _)| a.state().cmp(b.state()));
+

+
            for (id, patch, _) in issues {
+
                if let Ok(item) = IssueItem::try_from((profile, &repo, id, patch)) {
+
                    items.push(item);
+
                }
+
            }
+
        }
+

+
        let table = Widget::new(Table::new(&items, header, widths, theme.clone()))
+
            .highlight(theme.colors.item_list_highlighted_bg);
+

+
        Self { table, shortcuts }
+
    }
+

+
    pub fn selected_item(&self) -> Option<&IssueItem> {
+
        self.table.selection()
    }
}

impl WidgetComponent for IssueBrowser {
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let label_w = self
-
            .label
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(1))
-
            .unwrap_size();
        let shortcuts_h = self
            .shortcuts
            .query(Attribute::Height)
@@ -78,17 +116,16 @@ impl WidgetComponent for IssueBrowser {
            .unwrap_size();
        let layout = layout::root_component(area, shortcuts_h);

-
        self.label
-
            .view(frame, layout::centered_label(label_w, layout[0]));
+
        self.table.view(frame, layout[0]);
        self.shortcuts.view(frame, layout[1])
    }

    fn state(&self) -> State {
-
        State::None
+
        self.table.state()
    }

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.table.perform(cmd)
    }
}

@@ -221,17 +258,16 @@ pub fn patches(theme: &Theme, id: &Id, profile: &Profile) -> Widget<PatchBrowser
    Widget::new(PatchBrowser::new(profile, id, shortcuts, theme.clone()))
}

-
pub fn issues(theme: &Theme) -> Widget<IssueBrowser> {
+
pub fn issues(theme: &Theme, id: &Id, profile: &Profile) -> Widget<IssueBrowser> {
    let shortcuts = common::shortcuts(
        theme,
        vec![
            common::shortcut(theme, "tab", "section"),
+
            common::shortcut(theme, "↑/↓", "navigate"),
+
            common::shortcut(theme, "enter", "show"),
            common::shortcut(theme, "q", "quit"),
        ],
    );

-
    let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
-
    let browser = IssueBrowser::new(not_implemented, shortcuts);
-

-
    Widget::new(browser)
+
    Widget::new(IssueBrowser::new(theme, profile, id, shortcuts))
}