tui: List issues

This commit is contained in:
Erik Kundt 2023-05-23 12:21:43 +02:00
parent 5f731fad0e
commit 56a61b61c9
5 changed files with 218 additions and 50 deletions

View File

@ -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
}

View File

@ -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(

View File

@ -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
}

View File

@ -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,

View File

@ -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))
}