Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Issue preview widgets in `issue select`
Merged did:key:z6MkswQE...2C1V opened 1 year ago
6 files changed +620 -89 f3feb9dd c62737ee
modified CHANGELOG.md
@@ -4,6 +4,10 @@

### Added

+
**Binary features:**
+

+
- Issue preview widgets in `issue select`
+

**Library features:**

- Widgets can be mutated in their render function
modified bin/commands/issue/select.rs
@@ -1,9 +1,10 @@
#[path = "select/ui.rs"]
mod ui;

+
use std::collections::{HashMap, HashSet};
use std::str::FromStr;

-
use anyhow::Result;
+
use anyhow::{bail, Result};

use termion::event::Key;

@@ -11,6 +12,8 @@ use ratatui::layout::Constraint;
use ratatui::style::Stylize;
use ratatui::text::{Line, Span, Text};

+
use radicle::cob::thread::CommentId;
+
use radicle::git::Oid;
use radicle::issue::IssueId;
use radicle::storage::git::Repository;
use radicle::Profile;
@@ -20,19 +23,23 @@ use radicle_tui as tui;
use tui::store;
use tui::store::StateValue;
use tui::ui::span;
-
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::widget::container::{
+
    Column, Container, Footer, FooterProps, Header, HeaderProps, SectionGroup, SectionGroupProps,
+
    SplitContainer, SplitContainerFocus, SplitContainerProps,
+
};
use tui::ui::widget::input::{TextView, TextViewProps};
+
use tui::ui::widget::list::{Tree, TreeProps};
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
-
use tui::ui::widget::{ToWidget, Widget};
-

+
use tui::ui::widget::{PredefinedLayout, ToWidget, Widget};
use tui::{BoxedAny, Channel, Exit, PageStack};

use crate::cob::issue;
-
use crate::ui::items::{Filter, IssueItem, IssueItemFilter};
+
use crate::ui::items::{CommentItem, Filter, IssueItem, IssueItemFilter};
+
use crate::ui::widget::{IssueDetails, IssueDetailsProps};

use self::ui::{Browser, BrowserProps};

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

type Selection = tui::Selection<IssueId>;

@@ -53,6 +60,37 @@ pub enum AppPage {
    Help,
}

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Section {
+
    #[default]
+
    Browser,
+
    Details,
+
    Comment,
+
}
+

+
impl TryFrom<usize> for Section {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: usize) -> Result<Self, Self::Error> {
+
        match value {
+
            0 => Ok(Section::Browser),
+
            1 => Ok(Section::Details),
+
            2 => Ok(Section::Comment),
+
            _ => bail!("Unknown section index: {}", value),
+
        }
+
    }
+
}
+

+
impl From<Section> for usize {
+
    fn from(section: Section) -> Self {
+
        match section {
+
            Section::Browser => 0,
+
            Section::Details => 1,
+
            Section::Comment => 2,
+
        }
+
    }
+
}
+

#[derive(Clone, Debug)]
pub struct BrowserState {
    items: Vec<IssueItem>,
@@ -64,12 +102,118 @@ pub struct BrowserState {

impl BrowserState {
    pub fn issues(&self) -> Vec<IssueItem> {
+
        self.issues_ref().into_iter().cloned().collect()
+
    }
+

+
    pub fn issues_ref(&self) -> Vec<&IssueItem> {
        self.items
            .iter()
            .filter(|patch| self.filter.matches(patch))
-
            .cloned()
            .collect()
    }
+

+
    pub fn selected_issue(&self) -> Option<&IssueItem> {
+
        self.selected
+
            .and_then(|selected| self.issues_ref().get(selected).copied())
+
    }
+
}
+

+
impl BrowserState {
+
    pub fn show_search(&mut self) {
+
        self.show_search = true;
+
    }
+

+
    pub fn hide_search(&mut self) {
+
        self.show_search = false;
+
    }
+

+
    pub fn apply_search(&mut self) {
+
        self.search.apply();
+
    }
+

+
    pub fn reset_search(&mut self) {
+
        self.search.reset();
+
    }
+

+
    pub fn search(&mut self, value: String) {
+
        self.search.write(value);
+
        self.filter_items();
+
    }
+

+
    pub fn filter_items(&mut self) {
+
        self.filter = IssueItemFilter::from_str(&self.search.read()).unwrap_or_default();
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct CommentState {
+
    /// Current text view cursor.
+
    cursor: (usize, usize),
+
}
+

+
impl CommentState {
+
    pub fn reset_cursor(&mut self) {
+
        self.cursor = (0, 0);
+
    }
+

+
    pub fn update_cursor(&mut self, cursor: (usize, usize)) {
+
        self.cursor = cursor;
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct PreviewState {
+
    /// If preview is visible.
+
    show: bool,
+
    /// Currently selected issue item.
+
    issue: Option<IssueItem>,
+
    /// Tree selection per issue.
+
    selected_comments: HashMap<IssueId, Vec<CommentId>>,
+
    /// State of currently selected comment
+
    comment: CommentState,
+
}
+

+
impl PreviewState {
+
    pub fn root_comments(&self) -> Vec<CommentItem> {
+
        self.issue
+
            .as_ref()
+
            .map(|item| item.root_comments())
+
            .unwrap_or_default()
+
    }
+

+
    pub fn selected_comment(&self) -> Option<&CommentItem> {
+
        self.issue.as_ref().and_then(|item| {
+
            self.selected_comments
+
                .get(&item.id)
+
                .and_then(|selection| selection.last().copied())
+
                .and_then(|comment_id| {
+
                    item.comments
+
                        .iter()
+
                        .filter(|item| item.id == comment_id)
+
                        .collect::<Vec<_>>()
+
                        .first()
+
                        .cloned()
+
                })
+
        })
+
    }
+

+
    pub fn selected_comment_ids(&self) -> Vec<String> {
+
        self.issue
+
            .as_ref()
+
            .and_then(|item| self.selected_comments.get(&item.id))
+
            .map(|selected| selected.iter().map(|oid| oid.to_string()).collect())
+
            .unwrap_or_default()
+
    }
+

+
    pub fn opened_comments(&self) -> HashSet<Vec<String>> {
+
        let mut opened = HashSet::new();
+
        if let Some(item) = &self.issue {
+
            for comment in item.root_comments() {
+
                append_opened(&mut opened, vec![], comment.clone());
+
            }
+
        }
+
        opened
+
    }
}

#[derive(Clone, Debug)]
@@ -83,6 +227,8 @@ pub struct State {
    mode: Mode,
    pages: PageStack<AppPage>,
    browser: BrowserState,
+
    preview: PreviewState,
+
    section: Option<Section>,
    help: HelpState,
}

@@ -103,16 +249,35 @@ impl TryFrom<&Context> for State {
        }
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));

+
        // Pre-select first comment
+
        let mut selected_comments = HashMap::new();
+
        for item in &items {
+
            selected_comments.insert(
+
                item.id,
+
                item.root_comments()
+
                    .first()
+
                    .map(|comment| vec![comment.id])
+
                    .unwrap_or_default(),
+
            );
+
        }
+

        Ok(Self {
            mode: context.mode.clone(),
            pages: PageStack::new(vec![AppPage::Browser]),
            browser: BrowserState {
-
                items,
+
                items: items.clone(),
                selected: Some(0),
                filter,
                search,
                show_search: false,
            },
+
            preview: PreviewState {
+
                show: false,
+
                issue: items.first().cloned(),
+
                selected_comments,
+
                comment: CommentState { cursor: (0, 0) },
+
            },
+
            section: Some(Section::Browser),
            help: HelpState {
                scroll: 0,
                cursor: (0, 0),
@@ -122,10 +287,12 @@ impl TryFrom<&Context> for State {
}

pub enum Message {
+
    Quit,
    Exit {
-
        selection: Option<Selection>,
+
        operation: Option<IssueOperation>,
    },
-
    Select {
+
    ExitFromMode,
+
    SelectIssue {
        selected: Option<usize>,
    },
    OpenSearch,
@@ -134,6 +301,16 @@ pub enum Message {
    },
    ApplySearch,
    CloseSearch,
+
    TogglePreview,
+
    FocusSection {
+
        section: Option<Section>,
+
    },
+
    SelectComment {
+
        selected: Option<Vec<CommentId>>,
+
    },
+
    ScrollComment {
+
        cursor: (usize, usize),
+
    },
    OpenHelp,
    LeavePage,
    ScrollHelp {
@@ -147,39 +324,88 @@ impl store::State<Selection> for State {

    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
        match message {
-
            Message::Exit { selection } => Some(Exit { value: selection }),
-
            Message::Select { selected } => {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => self.browser.selected_issue().map(|issue| Exit {
+
                value: Some(Selection {
+
                    operation: operation.map(|op| op.to_string()),
+
                    ids: vec![issue.id],
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::ExitFromMode => {
+
                let operation = match self.mode {
+
                    Mode::Operation => Some(IssueOperation::Show.to_string()),
+
                    Mode::Id => None,
+
                };
+

+
                self.browser.selected_issue().map(|issue| Exit {
+
                    value: Some(Selection {
+
                        operation,
+
                        ids: vec![issue.id],
+
                        args: vec![],
+
                    }),
+
                })
+
            }
+
            Message::SelectIssue { selected } => {
                self.browser.selected = selected;
+
                self.preview.issue = self.browser.selected_issue().cloned();
+
                self.preview.comment.reset_cursor();
+
                None
+
            }
+
            Message::TogglePreview => {
+
                self.preview.show = !self.preview.show;
+
                self.section = Some(Section::Browser);
+
                None
+
            }
+
            Message::FocusSection { section } => {
+
                self.section = section;
+
                None
+
            }
+
            Message::SelectComment { selected } => {
+
                if let Some(item) = &self.preview.issue {
+
                    self.preview
+
                        .selected_comments
+
                        .insert(item.id, selected.unwrap_or(vec![]));
+
                }
+
                self.preview.comment.reset_cursor();
+
                None
+
            }
+
            Message::ScrollComment { cursor } => {
+
                self.preview.comment.update_cursor(cursor);
                None
            }
            Message::OpenSearch => {
-
                self.browser.show_search = true;
+
                self.browser.show_search();
                None
            }
            Message::UpdateSearch { value } => {
-
                self.browser.search.write(value);
-
                self.browser.filter =
-
                    IssueItemFilter::from_str(&self.browser.search.read()).unwrap_or_default();
+
                self.browser.search(value);

                if let Some(selected) = self.browser.selected {
                    if selected > self.browser.issues().len() {
                        self.browser.selected = Some(0);
+
                        self.preview.issue = self.browser.issues().first().cloned();
+
                    } else {
+
                        self.preview.issue = self.browser.issues().get(selected).cloned();
                    }
+
                } else {
+
                    self.preview.issue = None;
                }

                None
            }
            Message::ApplySearch => {
-
                self.browser.search.apply();
-
                self.browser.show_search = false;
+
                self.browser.hide_search();
+
                self.browser.apply_search();
                None
            }
            Message::CloseSearch => {
-
                self.browser.search.reset();
-
                self.browser.show_search = false;
-
                self.browser.filter =
-
                    IssueItemFilter::from_str(&self.browser.search.read()).unwrap_or_default();
+
                self.browser.hide_search();
+
                self.browser.reset_search();
+
                self.browser.filter_items();

+
                self.preview.issue = self.browser.selected_issue().cloned();
+
                self.preview.comment.reset_cursor();
                None
            }
            Message::OpenHelp => {
@@ -210,8 +436,8 @@ impl App {
        let tx = channel.tx.clone();

        let window = Window::default()
-
            .page(AppPage::Browser, browser_page(&state, &channel))
-
            .page(AppPage::Help, help_page(&state, &channel))
+
            .page(AppPage::Browser, browser_page(&channel))
+
            .page(AppPage::Help, help_page(&channel))
            .to_widget(tx.clone())
            .on_update(|state| {
                WindowProps::default()
@@ -224,28 +450,23 @@ impl App {
    }
}

-
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
    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 shortcuts = if state.browser.show_search {
                vec![("esc", "cancel"), ("enter", "apply")]
            } else {
-
                match state.mode {
-
                    Mode::Id => vec![("enter", "select"), ("/", "search")],
-
                    Mode::Operation => vec![
-
                        ("enter", "show"),
-
                        ("e", "edit"),
-
                        ("/", "search"),
-
                        ("?", "help"),
-
                    ],
+
                let mut shortcuts = match state.mode {
+
                    Mode::Id => vec![("enter", "select")],
+
                    Mode::Operation => vec![("enter", "show"), ("e", "edit")],
+
                };
+
                if state.section == Some(Section::Browser) {
+
                    shortcuts = [shortcuts, [("/", "search")].to_vec()].concat()
                }
+
                [shortcuts, [("p", "preview"), ("?", "help")].to_vec()].concat()
            };

            ShortcutsProps::default()
@@ -255,7 +476,32 @@ fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Mes
        });

    Page::default()
-
        .content(content)
+
        .content(
+
            SectionGroup::default()
+
                .section(browser(channel))
+
                .section(issue(channel))
+
                .section(comment(channel))
+
                .to_widget(tx.clone())
+
                .on_event(|_, vs, _| {
+
                    Some(Message::FocusSection {
+
                        section: vs.and_then(|vs| {
+
                            vs.unwrap_section_group()
+
                                .and_then(|sgs| sgs.focus)
+
                                .map(|s| s.try_into().unwrap_or_default())
+
                        }),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    SectionGroupProps::default()
+
                        .handle_keys(state.preview.show && !state.browser.show_search)
+
                        .layout(PredefinedLayout::Expandable3 {
+
                            left_only: !state.preview.show,
+
                        })
+
                        .focus(state.section.as_ref().map(|s| s.clone().into()))
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
        .shortcuts(shortcuts)
        .to_widget(tx.clone())
        .on_event(|key, _, props| {
@@ -266,8 +512,13 @@ fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Mes

            if props.handle_keys {
                match key {
-
                    Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
                    Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
+
                    Key::Char('p') => Some(Message::TogglePreview),
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    Key::Char('\n') => Some(Message::ExitFromMode),
+
                    Key::Char('e') => Some(Message::Exit {
+
                        operation: Some(IssueOperation::Edit),
+
                    }),
                    _ => None,
                }
            } else {
@@ -282,7 +533,117 @@ fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Mes
        })
}

-
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
fn browser(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    Browser::new(tx.clone())
+
        .to_widget(tx.clone())
+
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into())
+
}
+

+
fn issue(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    SplitContainer::default()
+
        .top(issue_details(channel))
+
        .bottom(comment_tree(channel))
+
        .to_widget(tx.clone())
+
        .on_update(|_| {
+
            SplitContainerProps::default()
+
                .heights([Constraint::Length(5), Constraint::Min(1)])
+
                .split_focus(SplitContainerFocus::Bottom)
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn issue_details(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    IssueDetails::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            IssueDetailsProps::default()
+
                .issue(state.preview.issue.clone())
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn comment_tree(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    Tree::<State, Message, CommentItem, String>::default()
+
        .to_widget(tx.clone())
+
        .on_event(|_, s, _| {
+
            Some(Message::SelectComment {
+
                selected: s.and_then(|s| {
+
                    s.unwrap_tree()
+
                        .map(|tree| tree.iter().map(|id| Oid::from_str(id).unwrap()).collect())
+
                }),
+
            })
+
        })
+
        .on_update(|state| {
+
            let root = &state.preview.root_comments();
+
            let opened = &state.preview.opened_comments();
+
            let selected = &state.preview.selected_comment_ids();
+

+
            TreeProps::<CommentItem, String>::default()
+
                .items(root.to_vec())
+
                .selected(Some(selected))
+
                .opened(Some(opened.clone()))
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
fn comment(channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    Container::default()
+
        .content(
+
            TextView::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, vs, _| {
+
                    let textview = vs.and_then(|p| p.unwrap_textview()).unwrap_or_default();
+
                    Some(Message::ScrollComment {
+
                        cursor: textview.cursor,
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    let comment = state.preview.selected_comment();
+
                    let body: String = comment
+
                        .map(|comment| comment.body.clone())
+
                        .unwrap_or_default();
+
                    let reactions = comment
+
                        .map(|comment| {
+
                            let reactions = comment.accumulated_reactions().iter().fold(
+
                                String::new(),
+
                                |all, (r, acc)| {
+
                                    if *acc > 1_usize {
+
                                        [all, format!("{}{} ", r, acc)].concat()
+
                                    } else {
+
                                        [all, format!("{} ", r)].concat()
+
                                    }
+
                                },
+
                            );
+
                            reactions
+
                        })
+
                        .unwrap_or_default();
+

+
                    TextViewProps::default()
+
                        .content(body)
+
                        .footer(Some(reactions))
+
                        .cursor(state.preview.comment.cursor)
+
                        .show_scroll_progress(true)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone())
+
}
+

+
fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
    let tx = channel.tx.clone();

    let content = Container::default()
@@ -344,7 +705,7 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
        .shortcuts(shortcuts)
        .to_widget(tx.clone())
        .on_event(|key, _, _| match key {
-
            Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
            Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
            Key::Char('?') => Some(Message::LeavePage),
            _ => None,
        })
@@ -405,6 +766,23 @@ fn help_text() -> Text<'static> {
                .to_vec(),
            ),
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Tab")).gray(),
+
                    Span::raw(": "),
+
                    Span::raw("focus next section").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Backtab")).gray(),
+
                    Span::raw(": "),
+
                    Span::raw("focus previous section").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
            Line::from(Span::raw("Specific keybindings").cyan()),
            Line::raw(""),
            Line::from(
@@ -427,7 +805,15 @@ fn help_text() -> Text<'static> {
                [
                    Span::raw(format!("{key:>10}", key = "e")).gray(),
                    Span::raw(": "),
-
                    Span::raw("Edit patch").gray().dim(),
+
                    Span::raw("Edit issue").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "p")).gray(),
+
                    Span::raw(": "),
+
                    Span::raw("Toggle issue preview").gray().dim(),
                ]
                .to_vec(),
            ),
@@ -479,3 +865,15 @@ fn help_text() -> Text<'static> {
        ]
        .to_vec())
}
+

+
fn append_opened(all: &mut HashSet<Vec<String>>, path: Vec<String>, comment: CommentItem) {
+
    all.insert([path.clone(), [comment.id.to_string()].to_vec()].concat());
+

+
    for reply in comment.replies {
+
        append_opened(
+
            all,
+
            [path.clone(), [comment.id.to_string()].to_vec()].concat(),
+
            reply,
+
        );
+
    }
+
}
modified bin/commands/issue/select/ui.rs
@@ -24,10 +24,8 @@ use tui::ui::widget::list::{Table, TableProps};
use tui::ui::widget::ViewProps;
use tui::ui::widget::{RenderProps, ToWidget, View};

-
use tui::{BoxedAny, Selection};
+
use tui::BoxedAny;

-
use crate::tui_issue::common::IssueOperation;
-
use crate::tui_issue::common::Mode;
use crate::ui::items::{IssueItem, IssueItemFilter};

use super::{Message, State};
@@ -36,12 +34,8 @@ type Widget = widget::Widget<State, Message>;

#[derive(Clone, Default)]
pub struct BrowserProps<'a> {
-
    /// Application mode: openation and id or id only.
-
    mode: Mode,
    /// Filtered issues.
    issues: Vec<IssueItem>,
-
    /// Current (selected) table index
-
    selected: Option<usize>,
    /// Issue statistics.
    stats: HashMap<String, usize>,
    /// Header columns
@@ -86,9 +80,7 @@ impl<'a> From<&State> for BrowserProps<'a> {
        ]);

        Self {
-
            mode: state.mode.clone(),
            issues,
-
            selected: state.browser.selected,
            stats,
            header: [
                Column::new(" ● ", Constraint::Length(3)),
@@ -143,7 +135,7 @@ impl Browser {
                        .on_event(|_, s, _| {
                            let (selected, _) =
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::Select {
+
                            Some(Message::SelectIssue {
                                selected: Some(selected),
                            })
                        })
@@ -217,33 +209,6 @@ impl View for Browser {
        } else {
            match key {
                Key::Char('/') => Some(Message::OpenSearch),
-
                Key::Char('\n') => {
-
                    let operation = match props.mode {
-
                        Mode::Operation => Some(IssueOperation::Show.to_string()),
-
                        Mode::Id => None,
-
                    };
-

-
                    props
-
                        .selected
-
                        .and_then(|selected| props.issues.get(selected))
-
                        .map(|issue| Message::Exit {
-
                            selection: Some(Selection {
-
                                operation,
-
                                ids: vec![issue.id],
-
                                args: vec![],
-
                            }),
-
                        })
-
                }
-
                Key::Char('e') => props
-
                    .selected
-
                    .and_then(|selected| props.issues.get(selected))
-
                    .map(|issue| Message::Exit {
-
                        selection: Some(Selection {
-
                            operation: Some(IssueOperation::Edit.to_string()),
-
                            ids: vec![issue.id],
-
                            args: vec![],
-
                        }),
-
                    }),
                _ => {
                    self.issues.handle_event(key);
                    None
modified bin/ui.rs
@@ -1,2 +1,3 @@
pub mod format;
pub mod items;
+
pub mod widget;
modified bin/ui/items.rs
@@ -1,3 +1,4 @@
+
use std::collections::HashMap;
use std::str::FromStr;

use nom::bytes::complete::{tag, take};
@@ -5,6 +6,7 @@ use nom::multi::separated_list0;
use nom::sequence::{delimited, preceded};
use nom::{IResult, Parser};

+
use radicle::cob::thread::{Comment, CommentId};
use radicle::cob::{Label, ObjectId, Timestamp, TypedId};
use radicle::git::Oid;
use radicle::identity::{Did, Identity};
@@ -19,15 +21,19 @@ use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, WriteRepository};
use radicle::Profile;

use ratatui::style::{Style, Stylize};
+
use ratatui::text::{Line, Text};
use ratatui::widgets::Cell;

+
use tui_tree_widget::TreeItem;
+

use radicle_tui as tui;
-
use tui::ui::widget::list::ToRow;

-
use super::super::git;
-
use super::format;
use tui::ui::span;
use tui::ui::theme::style;
+
use tui::ui::widget::list::{ToRow, ToTree};
+

+
use super::super::git;
+
use super::format;

pub trait Filter<T> {
    fn matches(&self, item: &T) -> bool;
@@ -60,6 +66,7 @@ impl AuthorItem {
}

#[derive(Clone, Debug)]
+
#[allow(dead_code)]
pub enum NotificationKindItem {
    Branch {
        name: String,
@@ -469,6 +476,8 @@ pub struct IssueItem {
    pub assignees: Vec<AuthorItem>,
    /// Time when issue was opened.
    pub timestamp: Timestamp,
+
    /// Comment timeline
+
    pub comments: Vec<CommentItem>,
}

impl IssueItem {
@@ -486,8 +495,22 @@ impl IssueItem {
                .map(|did| AuthorItem::new(Some(**did), profile))
                .collect::<Vec<_>>(),
            timestamp: issue.timestamp(),
+
            comments: issue
+
                .comments()
+
                .map(|(comment_id, comment)| {
+
                    CommentItem::new(profile, (id, issue.clone()), (*comment_id, comment.clone()))
+
                })
+
                .collect(),
        })
    }
+

+
    pub fn root_comments(&self) -> Vec<CommentItem> {
+
        self.comments
+
            .iter()
+
            .filter(|comment| comment.reply_to.is_none())
+
            .cloned()
+
            .collect::<Vec<_>>()
+
    }
}

impl ToRow<8> for IssueItem {
@@ -885,6 +908,103 @@ impl FromStr for PatchItemFilter {
    }
}

+
/// A `CommentItem` represents a comment COB and is constructed from an `Issue` and
+
/// a `Comment`.
+
#[derive(Clone, Debug)]
+
pub struct CommentItem {
+
    /// Comment OID.
+
    pub id: CommentId,
+
    /// Author of this comment.
+
    pub author: AuthorItem,
+
    /// The content of this comment.
+
    pub body: String,
+
    /// Reactions to this comment.
+
    pub reactions: Vec<char>,
+
    /// Time when patch was opened.
+
    pub timestamp: Timestamp,
+
    /// The parent OID if this is a reply.
+
    pub reply_to: Option<CommentId>,
+
    /// Replies to this comment.
+
    pub replies: Vec<CommentItem>,
+
}
+

+
impl CommentItem {
+
    pub fn new(profile: &Profile, issue: (IssueId, Issue), comment: (CommentId, Comment)) -> Self {
+
        let (issue_id, issue) = issue;
+
        let (comment_id, comment) = comment;
+

+
        Self {
+
            id: comment_id,
+
            author: AuthorItem::new(Some(NodeId::from(*comment.author().0)), profile),
+
            body: comment.body().to_string(),
+
            reactions: comment.reactions().iter().map(|r| r.0.emoji()).collect(),
+
            timestamp: comment.timestamp(),
+
            reply_to: comment.reply_to(),
+
            replies: issue
+
                .thread()
+
                .replies(&comment_id)
+
                .map(|(reply_id, reply)| {
+
                    CommentItem::new(
+
                        profile,
+
                        (issue_id, issue.clone()),
+
                        (*reply_id, reply.clone()),
+
                    )
+
                })
+
                .collect(),
+
        }
+
    }
+

+
    pub fn accumulated_reactions(&self) -> HashMap<char, usize> {
+
        let mut reactions: HashMap<char, usize> = HashMap::new();
+
        for reaction in &self.reactions {
+
            if let Some(count) = reactions.get_mut(reaction) {
+
                *count = count.saturating_add(1);
+
            } else {
+
                reactions.insert(*reaction, 1_usize);
+
            }
+
        }
+

+
        reactions
+
    }
+
}
+

+
impl ToTree<String> for CommentItem {
+
    fn rows(&self) -> Vec<TreeItem<'_, String>> {
+
        let mut children = vec![];
+
        for comment in &self.replies {
+
            children.extend(comment.rows());
+
        }
+

+
        let author = match &self.author.alias {
+
            Some(alias) => {
+
                if self.author.you {
+
                    span::alias(&format!("{} (you)", alias))
+
                } else {
+
                    span::alias(alias)
+
                }
+
            }
+
            None => match &self.author.human_nid {
+
                Some(nid) => span::alias(nid).dim(),
+
                None => span::blank(),
+
            },
+
        };
+
        let action = if self.reply_to.is_none() {
+
            "opened"
+
        } else {
+
            "commented"
+
        };
+
        let timestamp = span::timestamp(&format::timestamp(&self.timestamp));
+

+
        let text = Text::from(Line::from(
+
            [author, " ".into(), action.into(), " ".into(), timestamp].to_vec(),
+
        ));
+
        let item = TreeItem::new(self.id.to_string(), text, children)
+
            .expect("Identifiers need to be unique");
+

+
        vec![item]
+
    }
+
}
+

#[cfg(test)]
mod tests {
    use anyhow::Result;
modified bin/ui/widget.rs
@@ -1,16 +1,19 @@
use std::marker::PhantomData;

-
use ratatui::layout::Constraint;
+
use radicle::issue::{self, CloseReason};
+
use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Text};
+
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::Row;
use ratatui::Frame;

use radicle_tui as tui;

-
use tui::ui::span;
+
use tui::ui::theme::style;
use tui::ui::widget::{RenderProps, View, ViewProps};
+
use tui::ui::{layout, span};

+
use super::format;
use super::items::IssueItem;

#[derive(Clone, Default)]
@@ -48,11 +51,16 @@ impl<S, M> View for IssueDetails<S, M> {
            .and_then(|props| props.inner_ref::<IssueDetailsProps>())
            .unwrap_or(&default);

+
        let [area] = Layout::default()
+
            .constraints([Constraint::Min(1)])
+
            .horizontal_margin(1)
+
            .areas(render.area);
+

        if let Some(issue) = props.issue.as_ref() {
            let author = match &issue.author.alias {
                Some(alias) => {
                    if issue.author.you {
-
                        span::alias(&format!("{} (you)", alias))
+
                        span::alias(&format!("{}", alias))
                    } else {
                        span::alias(alias)
                    }
@@ -62,11 +70,36 @@ impl<S, M> View for IssueDetails<S, M> {
                    None => span::blank(),
                },
            };
+

            let did = match &issue.author.human_nid {
-
                Some(nid) => span::alias(nid).dim(),
+
                Some(nid) => {
+
                    if issue.author.you {
+
                        span::alias("(you)").dim().italic()
+
                    } else {
+
                        span::alias(nid).dim()
+
                    }
+
                }
                None => span::blank(),
            };

+
            let labels = format::labels(&issue.labels);
+

+
            let status = match issue.state {
+
                issue::State::Open => Text::styled("open", style::green()),
+
                issue::State::Closed { reason } => match reason {
+
                    CloseReason::Solved => Line::from(
+
                        [
+
                            Span::styled("closed", style::red()),
+
                            Span::raw(" "),
+
                            Span::styled("(solved)", style::red().italic().dim()),
+
                        ]
+
                        .to_vec(),
+
                    )
+
                    .into(),
+
                    CloseReason::Other => Text::styled("closed", style::red()),
+
                },
+
            };
+

            let table = ratatui::widgets::Table::new(
                [
                    Row::new([
@@ -81,11 +114,21 @@ impl<S, M> View for IssueDetails<S, M> {
                        Text::raw("Author").cyan(),
                        Line::from([author, " ".into(), did].to_vec()).into(),
                    ]),
-
                    Row::new([Text::raw("Status").cyan(), Text::raw("???").magenta()]),
+
                    Row::new([Text::raw("Labels").cyan(), Text::from(labels).blue()]),
+
                    Row::new([Text::raw("Status").cyan(), status]),
                ],
                [Constraint::Length(8), Constraint::Fill(1)],
            );
-
            frame.render_widget(table, render.area);
+

+
            frame.render_widget(table, area);
+
        } else {
+
            let center = layout::centered_rect(render.area, 50, 10);
+
            let hint = Text::from(span::default("No issue selected"))
+
                .centered()
+
                .light_magenta()
+
                .dim();
+

+
            frame.render_widget(hint, center);
        }
    }
}