From d2d95825aec6c01d416d2779be3c73853369f172 Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Sat, 9 Jul 2022 00:55:06 +0200 Subject: [PATCH] issue: Implement comment browser in tui Signed-off-by: Erik Kundt --- issue/src/lib.rs | 2 +- issue/src/tui.rs | 1 + issue/src/tui/app.rs | 85 +++++++++-------------- issue/src/tui/components.rs | 131 +++++++++++++++++++++++++++++++----- issue/src/tui/issue.rs | 83 +++++++++++++++++++++++ 5 files changed, 229 insertions(+), 73 deletions(-) create mode 100644 issue/src/tui/issue.rs diff --git a/issue/src/lib.rs b/issue/src/lib.rs index 75a15545..077292f3 100644 --- a/issue/src/lib.rs +++ b/issue/src/lib.rs @@ -259,7 +259,7 @@ fn create( let meta: Metadata = serde_yaml::from_str(&meta).context("failed to parse yaml front-matter")?; - store.create(&project, &meta.title, description.trim(), &meta.labels)?; + store.create(project, &meta.title, description.trim(), &meta.labels)?; } Ok(()) } diff --git a/issue/src/tui.rs b/issue/src/tui.rs index 13545c46..5840ae9e 100644 --- a/issue/src/tui.rs +++ b/issue/src/tui.rs @@ -1,2 +1,3 @@ pub mod app; pub mod components; +pub mod issue; diff --git a/issue/src/tui/app.rs b/issue/src/tui/app.rs index bbd9077b..ec368e07 100644 --- a/issue/src/tui/app.rs +++ b/issue/src/tui/app.rs @@ -1,22 +1,25 @@ use anyhow::Result; +use librad::git::storage::ReadOnly; + use tuirealm::event::{Key, KeyEvent, KeyModifiers}; use tuirealm::props::{AttrValue, Attribute}; use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect}; use tuirealm::{Frame, Sub, SubClause, SubEventClause}; -use librad::git::storage::ReadOnly; - -use radicle_common::cobs::issue::State as IssueState; use radicle_common::cobs::issue::*; use radicle_common::project; use radicle_terminal_tui as tui; + use tui::components::{ApplicationTitle, Shortcut, ShortcutBar, TabContainer}; use tui::{App, Tui}; use super::components::{CommentList, GlobalListener, IssueList}; +use super::issue; +use super::issue::{GroupedIssues, WrappedComment}; + /// Messages handled by this tui-application. #[derive(Debug, Eq, PartialEq)] pub enum Message { @@ -48,23 +51,11 @@ impl Default for Mode { } } -#[derive(Default)] -pub struct IssueGroups { - open: Vec<(IssueId, Issue)>, - closed: Vec<(IssueId, Issue)>, -} - -impl From<&IssueGroups> for Vec<(IssueId, Issue)> { - fn from(groups: &IssueGroups) -> Self { - [groups.open.clone(), groups.closed.clone()].concat() - } -} - /// App-window used by this application. #[derive(Default)] pub struct IssueTui { /// Issues currently displayed by this tui. - issues: IssueGroups, + issues: GroupedIssues, /// Represents the active view mode: Mode, /// True if application should quit. @@ -77,14 +68,14 @@ impl IssueTui { metadata: &project::Metadata, store: &IssueStore, ) -> Self { - let issues = match Self::load_issues(storage, metadata, store) { + let issues = match issue::load(storage, metadata, store) { Ok(issues) => issues, Err(_) => vec![], }; Self { - issues: Self::group_issues(&issues), - mode: Mode::Browser, + issues: GroupedIssues::from(&issues), + mode: Mode::default(), quit: false, } } @@ -111,43 +102,13 @@ impl IssueTui { .constraints( [ Constraint::Length(title_h), - Constraint::Length(container_h - 2), + Constraint::Length(container_h.saturating_sub(2)), Constraint::Length(shortcuts_h), ] .as_ref(), ) .split(area) } - - fn load_issues>( - storage: &S, - metadata: &project::Metadata, - store: &IssueStore, - ) -> Result> { - let mut issues = store.all(&metadata.urn)?; - Self::resolve_issues(storage, &mut issues); - Ok(issues) - } - - fn resolve_issues>(storage: &S, issues: &mut Vec<(IssueId, Issue)>) { - let _ = issues - .iter_mut() - .map(|(_, issue)| issue.resolve(&storage).ok()) - .collect::>(); - } - - fn group_issues(issues: &Vec<(IssueId, Issue)>) -> IssueGroups { - let mut open = issues.clone(); - let mut closed = issues.clone(); - - open.retain(|(_, issue)| issue.state() == IssueState::Open); - closed.retain(|(_, issue)| issue.state() != IssueState::Open); - - IssueGroups { - open: open, - closed: closed, - } - } } impl Tui for IssueTui { @@ -189,7 +150,7 @@ impl Tui for IssueTui { ], )?; - app.mount(Id::Detail, CommentList::<()>::new(vec![]), vec![])?; + app.mount(Id::Detail, CommentList::<()>::new(None, vec![]), vec![])?; app.mount( Id::Shortcuts, @@ -236,17 +197,31 @@ impl Tui for IssueTui { Message::Quit => self.quit = true, Message::EnterDetail(issue_id) => { let issues = Vec::<(IssueId, Issue)>::from(&self.issues); - if let Some((_, issue)) = issues.iter().find(|(id, _)| *id == issue_id) { + if let Some((id, issue)) = issues.iter().find(|(id, _)| *id == issue_id) { let comments = issue .comments() .iter() - .map(|comment| comment.clone()) + .map(|comment| WrappedComment::Reply { + comment: comment.clone(), + }) .collect::>(); self.mode = Mode::Detail; - app.remount(Id::Detail, CommentList::new(comments), vec![]) - .ok(); + let comments = [ + vec![WrappedComment::Root { + comment: issue.comment.clone(), + }], + comments, + ] + .concat(); + + app.remount( + Id::Detail, + CommentList::new(Some((*id, issue.clone())), comments), + vec![], + ) + .ok(); app.activate(Id::Detail).ok(); } } diff --git a/issue/src/tui/components.rs b/issue/src/tui/components.rs index 5d8fdbaf..4c26b99c 100644 --- a/issue/src/tui/components.rs +++ b/issue/src/tui/components.rs @@ -5,24 +5,27 @@ use timeago; use librad::collaborative_objects::ObjectId; -use tui_realm_stdlib::Phantom; +use tui_realm_stdlib::{utils, Phantom}; use tuirealm::command::{Cmd, CmdResult, Direction}; use tuirealm::event::{Event, Key, KeyEvent}; -use tuirealm::props::{AttrValue, Attribute, Color, Props, Style}; -use tuirealm::tui::layout::Rect; +use tuirealm::props::{AttrValue, Attribute, Color, Props, Style, TextSpan}; +use tuirealm::tui::layout::{Constraint, Layout, Rect}; use tuirealm::tui::style::Modifier; use tuirealm::tui::text::{Span, Spans}; use tuirealm::tui::widgets::{List as TuiList, ListItem, ListState as TuiListState}; use tuirealm::{Component, Frame, MockComponent, NoUserEvent, State, StateValue}; use radicle_common::cobs::issue::*; -use radicle_common::cobs::Comment; +use radicle_common::cobs::Timestamp; + use radicle_terminal_tui as tui; -use tui::components::{ApplicationTitle, ShortcutBar, TabContainer}; + +use tui::components::{ApplicationTitle, ContextBar, ShortcutBar, TabContainer}; use tui::state::ListState; use super::app::Message; +use super::issue::WrappedComment; /// Since `terminal-tui` does not know the type of messages that are being /// passed around in the app, the following handlers need to be implemented for @@ -143,7 +146,7 @@ impl IssueList { } impl MockComponent for IssueList { - fn view(&mut self, render: &mut Frame, area: Rect) { + fn view(&mut self, frame: &mut Frame, area: Rect) { let items = self .issues .items() @@ -161,7 +164,7 @@ impl MockComponent for IssueList { let mut state: TuiListState = TuiListState::default(); state.select(Some(self.issues.items().selected_index())); - render.render_stateful_widget(list, area, &mut state); + frame.render_stateful_widget(list, area, &mut state); } fn query(&self, attr: Attribute) -> Option { @@ -205,34 +208,119 @@ impl Component for IssueList { pub struct CommentList { attributes: Props, - comments: ListState>, + comments: ListState>, + issue: Option<(IssueId, Issue)>, } impl CommentList { - pub fn new(comments: Vec>) -> Self { + pub fn new(issue: Option<(IssueId, Issue)>, comments: Vec>) -> Self { Self { attributes: Props::default(), comments: ListState::new(comments), + issue: issue, } } - fn items(&self, comment: &Comment) -> ListItem { - let lines = vec![Spans::from(Span::styled( - comment.body.clone(), - Style::default().fg(Color::Rgb(117, 113, 249)), - ))]; + fn items(&self, comment: &WrappedComment, width: u16) -> ListItem { + let (author, body, reactions, timestamp, indent) = comment.author_info(); + let reactions = reactions + .iter() + .map(|(r, _)| format!("{} ", r.emoji)) + .collect::(); + + let lines = [ + Self::body(body, indent, width), + vec![ + Spans::from(String::new()), + Spans::from(Self::meta(author, reactions, timestamp, indent)), + Spans::from(String::new()), + ], + ] + .concat(); ListItem::new(lines) } + + fn body<'a>(body: String, indent: u16, width: u16) -> Vec> { + let props = Props::default(); + let body = TextSpan::new(body).fg(Color::Rgb(150, 150, 150)); + + let lines = utils::wrap_spans(&[body], (width - indent) as usize, &props) + .iter() + .map(|line| Spans::from(format!("{}{}", whitespaces(indent), line.0[0].content))) + .collect::>(); + lines + } + + fn meta<'a>( + author: String, + reactions: String, + timestamp: Timestamp, + indent: u16, + ) -> Vec> { + let fmt = timeago::Formatter::new(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let timeago = Duration::from_secs(now - timestamp.as_secs()); + + vec![ + Span::raw(whitespaces(indent)), + Span::styled( + author, + Style::default() + .fg(Color::Rgb(79, 75, 187)) + .add_modifier(Modifier::ITALIC), + ), + Span::raw(whitespaces(1)), + Span::styled( + fmt.convert(timeago), + Style::default() + .fg(Color::Rgb(70, 70, 70)) + .add_modifier(Modifier::ITALIC), + ), + Span::raw(whitespaces(1)), + Span::raw(reactions), + ] + } } impl MockComponent for CommentList { - fn view(&mut self, render: &mut Frame, area: Rect) { + fn view(&mut self, frame: &mut Frame, area: Rect) { + use tuirealm::tui::layout::Direction; + + let mut context = match &self.issue { + Some((id, issue)) => ContextBar::new( + "Issue", + &format!("{}", id), + issue.title(), + &issue.author().name(), + &format!("{}", self.comments.items().count()), + ), + None => ContextBar::new("Issue", "", "", "", ""), + }; + let context_h = context.query(Attribute::Height).unwrap().unwrap_size(); + let spacer_h = 1; + + let list_h = area.height.saturating_sub(context_h); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(list_h.saturating_sub(spacer_h)), + Constraint::Length(context_h), + Constraint::Length(spacer_h), + ] + .as_ref(), + ) + .split(area); + let items = self .comments .items() .all() .iter() - .map(|comment| self.items(comment)) + .map(|comment| self.items(comment, area.width)) .collect::>(); let list = TuiList::new(items) @@ -243,7 +331,9 @@ impl MockComponent for CommentList { let mut state: TuiListState = TuiListState::default(); state.select(Some(self.comments.items().selected_index())); - render.render_stateful_widget(list, area, &mut state); + frame.render_stateful_widget(list, layout[0], &mut state); + + context.view(frame, layout[1]); } fn query(&self, attr: Attribute) -> Option { @@ -290,3 +380,10 @@ impl Component for CommentList { } } } + +pub fn whitespaces(indent: u16) -> String { + match String::from_utf8(vec![b' '; indent as usize]) { + Ok(spaces) => spaces, + Err(_) => String::new(), + } +} diff --git a/issue/src/tui/issue.rs b/issue/src/tui/issue.rs new file mode 100644 index 00000000..e497f2d2 --- /dev/null +++ b/issue/src/tui/issue.rs @@ -0,0 +1,83 @@ +use anyhow::Result; + +use librad::git::storage::ReadOnly; + +use radicle_common::cobs::issue::State; +use radicle_common::cobs::issue::*; +use radicle_common::cobs::{Comment, Reaction, Timestamp}; + +use radicle_common::project; + +#[derive(Default)] +pub struct GroupedIssues { + pub open: Vec<(IssueId, Issue)>, + pub closed: Vec<(IssueId, Issue)>, +} + +impl From<&GroupedIssues> for Vec<(IssueId, Issue)> { + fn from(groups: &GroupedIssues) -> Self { + [groups.open.clone(), groups.closed.clone()].concat() + } +} + +impl From<&Vec<(IssueId, Issue)>> for GroupedIssues { + fn from(issues: &Vec<(IssueId, Issue)>) -> Self { + let mut open = issues.clone(); + let mut closed = issues.clone(); + + open.retain(|(_, issue)| issue.state() == State::Open); + closed.retain(|(_, issue)| issue.state() != State::Open); + + Self { + open: open, + closed: closed, + } + } +} + +#[derive(Clone)] +pub enum WrappedComment { + Root { comment: Comment<()> }, + Reply { comment: Comment }, +} + +impl WrappedComment { + pub fn author_info(&self) -> (String, String, Vec<(&Reaction, &usize)>, Timestamp, u16) { + let (author, body, reactions, timestamp, indent) = match self { + WrappedComment::Root { comment } => ( + comment.author.name(), + comment.body.clone(), + comment.reactions.iter().collect::>(), + comment.timestamp, + 0, + ), + WrappedComment::Reply { comment } => ( + comment.author.name(), + comment.body.clone(), + comment.reactions.iter().collect::>(), + comment.timestamp, + 4, + ), + }; + + (author, body, reactions, timestamp, indent) + } +} + +pub fn load>( + storage: &S, + metadata: &project::Metadata, + store: &IssueStore, +) -> Result> { + let mut issues = store.all(&metadata.urn)?; + resolve(storage, &mut issues); + + Ok(issues) +} + +pub fn resolve>(storage: &S, issues: &mut Vec<(IssueId, Issue)>) { + let _ = issues + .iter_mut() + .map(|(_, issue)| issue.resolve(&storage).ok()) + .collect::>(); +}