Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin(issue): Rename `select` -> `list` operation
Erik Kundt committed 1 year ago
commit 0b2241977d1de4c5f1fb0faf8e832d368f391ee0
parent 89ce751
5 files changed +1052 -1054
modified bin/commands/issue.rs
@@ -1,7 +1,7 @@
#[path = "issue/common.rs"]
mod common;
-
#[path = "issue/select.rs"]
-
mod select;
+
#[path = "issue/list.rs"]
+
mod list;

use std::ffi::OsString;

@@ -31,11 +31,11 @@ pub const HELP: Help = Help {
    usage: r#"
Usage

-
    rad-tui patch select [<option>...]
+
    rad-tui issue list [<option>...]

Select options

-
    --mode <MODE>           Set selection mode; see MODE below (default: operation)
+
    --mode <MODE>       Set list mode; see MODE below (default: operation)

    The MODE argument can be 'operation' or 'id'. 'operation' selects an issue id and
    an operation, whereas 'id' selects an issue id only.
@@ -52,16 +52,16 @@ pub struct Options {
}

pub enum Operation {
-
    Select { opts: SelectOptions },
+
    List { opts: ListOptions },
}

#[derive(PartialEq, Eq)]
pub enum OperationName {
-
    Select,
+
    List,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub struct SelectOptions {
+
pub struct ListOptions {
    mode: common::Mode,
    filter: cob::issue::Filter,
}
@@ -73,7 +73,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();
+
        let mut list_opts = ListOptions::default();

        while let Some(arg) = parser.next()? {
            match arg {
@@ -82,40 +82,38 @@ impl Args for Options {
                }

                // select options.
-
                Long("mode") | Short('m') if op == Some(OperationName::Select) => {
+
                Long("mode") | Short('m') if op == Some(OperationName::List) => {
                    let val = parser.value()?;
                    let val = val.to_str().unwrap_or_default();

-
                    select_opts.mode = match val {
+
                    list_opts.mode = match val {
                        "operation" => common::Mode::Operation,
                        "id" => common::Mode::Id,
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
                    };
                }
-
                Long("all") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(None);
+
                Long("all") if op == Some(OperationName::List) => {
+
                    list_opts.filter = list_opts.filter.with_state(None);
                }
-
                Long("open") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(Some(issue::State::Open));
+
                Long("open") if op == Some(OperationName::List) => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Open));
                }
-
                Long("solved") if op == Some(OperationName::Select) => {
-
                    select_opts.filter =
-
                        select_opts.filter.with_state(Some(issue::State::Closed {
-
                            reason: issue::CloseReason::Solved,
-
                        }));
+
                Long("solved") if op == Some(OperationName::List) => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Closed {
+
                        reason: issue::CloseReason::Solved,
+
                    }));
                }
-
                Long("closed") if op == Some(OperationName::Select) => {
-
                    select_opts.filter =
-
                        select_opts.filter.with_state(Some(issue::State::Closed {
-
                            reason: issue::CloseReason::Other,
-
                        }));
+
                Long("closed") if op == Some(OperationName::List) => {
+
                    list_opts.filter = list_opts.filter.with_state(Some(issue::State::Closed {
+
                        reason: issue::CloseReason::Other,
+
                    }));
                }
-
                Long("assigned") if op == Some(OperationName::Select) => {
+
                Long("assigned") if op == Some(OperationName::List) => {
                    if let Ok(val) = parser.value() {
-
                        select_opts.filter =
-
                            select_opts.filter.with_assginee(terminal::args::did(&val)?);
+
                        list_opts.filter =
+
                            list_opts.filter.with_assginee(terminal::args::did(&val)?);
                    } else {
-
                        select_opts.filter = select_opts.filter.with_assgined(true);
+
                        list_opts.filter = list_opts.filter.with_assgined(true);
                    }
                }

@@ -127,7 +125,7 @@ impl Args for Options {
                }

                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "select" => op = Some(OperationName::Select),
+
                    "list" => op = Some(OperationName::List),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
                _ => return Err(anyhow!(arg.unexpected())),
@@ -135,7 +133,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 },
+
            OperationName::List => Operation::List { opts: list_opts },
        };
        Ok((Options { op, repo }, vec![]))
    }
@@ -151,7 +149,7 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
    let terminal_info = TERMINAL_INFO.clone();

    match options.op {
-
        Operation::Select { opts } => {
+
        Operation::List { opts } => {
            let profile = ctx.profile()?;
            let rid = options.repo.unwrap_or(rid);
            let repository = profile.storage.repository(rid).unwrap();
@@ -159,23 +157,23 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
            if let Err(err) = crate::log::enable() {
                println!("{}", err);
            }
-
            log::info!("Starting issue selection interface in project {}..", rid);
+
            log::info!("Starting issue listing interface in project {}..", rid);

-
            let context = select::Context {
+
            let context = list::Context {
                profile,
                repository,
                mode: opts.mode,
                filter: opts.filter.clone(),
            };

-
            let output = select::App::new(context, terminal_info).run().await?;
+
            let output = list::App::new(context, terminal_info).run().await?;

            let output = output
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
                .unwrap_or_default();

            log::info!("About to print to `stderr`: {}", output);
-
            log::info!("Exiting issue selection interface..");
+
            log::info!("Exiting issue listing interface..");

            eprint!("{output}");
        }
added bin/commands/issue/list.rs
@@ -0,0 +1,693 @@
+
#[path = "list/ui.rs"]
+
mod ui;
+

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

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

+
use ratatui::Viewport;
+
use termion::event::Key;
+

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::Text;
+

+
use radicle::cob::thread::CommentId;
+
use radicle::git::Oid;
+
use radicle::issue::IssueId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
use radicle_tui as tui;
+

+
use tui::store;
+
use tui::ui::rm::widget::container::{
+
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
+
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
+
};
+
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::list::{Tree, TreeProps};
+
use tui::ui::rm::widget::window::{
+
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
+
};
+
use tui::ui::rm::widget::{PredefinedLayout, ToWidget, Widget};
+
use tui::ui::theme::Theme;
+
use tui::ui::Column;
+
use tui::ui::{span, BufferedValue};
+
use tui::{BoxedAny, Channel, Exit, PageStack};
+

+
use crate::cob::issue;
+
use crate::settings::{self, ThemeBundle, ThemeMode};
+
use crate::ui::items::{CommentItem, IssueItem, IssueItemFilter};
+
use crate::ui::rm::{BrowserState, IssueDetails, IssueDetailsProps};
+
use crate::ui::TerminalInfo;
+

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

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

+
type Selection = tui::Selection<IssueId>;
+

+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub mode: Mode,
+
    pub filter: issue::Filter,
+
}
+

+
pub struct App {
+
    context: Context,
+
    terminal_info: TerminalInfo,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+
pub enum AppPage {
+
    Browser,
+
    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 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: TextViewState,
+
}
+

+
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)]
+
pub struct HelpState {
+
    text: TextViewState,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct State {
+
    mode: Mode,
+
    pages: PageStack<AppPage>,
+
    browser: BrowserState<IssueItem, IssueItemFilter>,
+
    preview: PreviewState,
+
    section: Option<Section>,
+
    help: HelpState,
+
    theme: Theme,
+
}
+

+
impl TryFrom<(&Context, &TerminalInfo)> for State {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Context, &TerminalInfo)) -> Result<Self, Self::Error> {
+
        let (context, terminal_info) = value;
+
        let settings = settings::Settings::default();
+

+
        let issues = issue::all(&context.profile, &context.repository)?;
+
        let search = BufferedValue::new(context.filter.to_string());
+
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();
+

+
        let default_bundle = ThemeBundle::default();
+
        let theme_bundle = settings.theme.active_bundle().unwrap_or(&default_bundle);
+
        let theme = match settings.theme.mode() {
+
            ThemeMode::Auto => {
+
                if terminal_info.is_dark() {
+
                    theme_bundle.dark.clone()
+
                } else {
+
                    theme_bundle.light.clone()
+
                }
+
            }
+
            ThemeMode::Light => theme_bundle.light.clone(),
+
            ThemeMode::Dark => theme_bundle.dark.clone(),
+
        };
+

+
        // Convert into UI items
+
        let mut items = vec![];
+
        for issue in issues {
+
            if let Ok(item) = IssueItem::new(&context.profile, issue.clone()) {
+
                items.push(item);
+
            }
+
        }
+
        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::build(items.clone(), filter, search),
+
            preview: PreviewState {
+
                show: true,
+
                issue: items.first().cloned(),
+
                selected_comments,
+
                comment: TextViewState::default(),
+
            },
+
            section: Some(Section::Browser),
+
            help: HelpState {
+
                text: TextViewState::default().content(help_text()),
+
            },
+
            theme,
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Quit,
+
    Exit { operation: Option<IssueOperation> },
+
    ExitFromMode,
+
    SelectIssue { selected: Option<usize> },
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
+
    TogglePreview,
+
    FocusSection { section: Option<Section> },
+
    SelectComment { selected: Option<Vec<CommentId>> },
+
    ScrollComment { state: TextViewState },
+
    OpenHelp,
+
    LeavePage,
+
    ScrollHelp { state: TextViewState },
+
}
+

+
impl store::Update<Message> for State {
+
    type Return = Selection;
+

+
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => self.browser.selected_item().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_item().map(|issue| Exit {
+
                    value: Some(Selection {
+
                        operation,
+
                        ids: vec![issue.id],
+
                        args: vec![],
+
                    }),
+
                })
+
            }
+
            Message::SelectIssue { selected } => {
+
                self.browser.select_item(selected);
+
                self.preview.issue = self.browser.selected_item().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 { state } => {
+
                self.preview.comment = state;
+
                None
+
            }
+
            Message::OpenSearch => {
+
                self.browser.show_search();
+
                None
+
            }
+
            Message::UpdateSearch { value } => {
+
                self.browser.update_search(value);
+
                self.preview.issue = self.browser.select_first_item().cloned();
+
                None
+
            }
+
            Message::ApplySearch => {
+
                self.browser.hide_search();
+
                self.browser.apply_search();
+
                None
+
            }
+
            Message::CloseSearch => {
+
                self.browser.hide_search();
+
                self.browser.reset_search();
+

+
                self.preview.issue = self.browser.selected_item().cloned();
+
                self.preview.comment.reset_cursor();
+
                None
+
            }
+
            Message::OpenHelp => {
+
                self.pages.push(AppPage::Help);
+
                None
+
            }
+
            Message::LeavePage => {
+
                self.pages.pop();
+
                None
+
            }
+
            Message::ScrollHelp { state } => {
+
                self.help.text = state;
+
                None
+
            }
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn new(context: Context, terminal_info: TerminalInfo) -> Self {
+
        Self {
+
            context,
+
            terminal_info,
+
        }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let channel = Channel::default();
+
        let state = State::try_from((&self.context, &self.terminal_info))?;
+
        let tx = channel.tx.clone();
+

+
        let window = Window::default()
+
            .page(AppPage::Browser, browser_page(&channel))
+
            .page(AppPage::Help, help_page(&channel))
+
            .to_widget(tx.clone())
+
            .on_update(|state| {
+
                WindowProps::default()
+
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browser).clone())
+
                    .to_boxed_any()
+
                    .into()
+
            });
+

+
        tui::rm(state, window, Viewport::Inline(20), channel).await
+
    }
+
}
+

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

+
    let shortcuts = Shortcuts::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            let shortcuts = if state.browser.is_search_shown() {
+
                vec![("esc", "cancel"), ("enter", "apply")]
+
            } else {
+
                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", "toggle preview"), ("?", "help")].to_vec()].concat()
+
            };
+

+
            ShortcutsProps::default()
+
                .shortcuts(&shortcuts)
+
                .shortcuts_keys_style(state.theme.shortcuts_keys_style)
+
                .shortcuts_action_style(state.theme.shortcuts_action_style)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    Page::default()
+
        .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.is_search_shown())
+
                        .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| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+

+
            if props.handle_keys {
+
                match key {
+
                    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 {
+
                None
+
            }
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .handle_keys(!state.browser.is_search_shown())
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

+
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(|state| {
+
            SplitContainerProps::default()
+
                .heights([Constraint::Length(5), Constraint::Min(1)])
+
                .border_style(state.theme.border_style)
+
                .focus_border_style(state.theme.focus_border_style)
+
                .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())
+
                .dim(state.theme.dim_no_focus)
+
                .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()))
+
                .dim(state.theme.dim_no_focus)
+
                .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 state = vs.and_then(|p| p.unwrap_textview()).unwrap_or_default();
+
                    Some(Message::ScrollComment { state })
+
                })
+
                .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()
+
                        .state(Some(state.preview.comment.clone().content(body)))
+
                        .footer(Some(reactions))
+
                        .show_scroll_progress(true)
+
                        .dim(state.theme.dim_no_focus)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone())
+
        .on_update(|state| {
+
            ContainerProps::default()
+
                .border_style(state.theme.border_style)
+
                .focus_border_style(state.theme.focus_border_style)
+
                .to_boxed_any()
+
                .into()
+
        })
+
}
+

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

+
    let content = Container::default()
+
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
+
            HeaderProps::default()
+
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
+
                .to_boxed_any()
+
                .into()
+
        }))
+
        .content(
+
            TextView::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, view_state, _| {
+
                    view_state
+
                        .and_then(|tv| tv.unwrap_textview())
+
                        .map(|tvs| Message::ScrollHelp { state: tvs })
+
                })
+
                .on_update(|state: &State| {
+
                    TextViewProps::default()
+
                        .state(Some(state.help.text.clone()))
+
                        .dim(state.theme.dim_no_focus)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .footer(
+
            Footer::default()
+
                .to_widget(tx.clone())
+
                .on_update(|state: &State| {
+
                    FooterProps::default()
+
                        .columns(
+
                            [
+
                                Column::new(Text::raw(""), Constraint::Fill(1)),
+
                                Column::new(
+
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
+
                                    Constraint::Min(4),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone())
+
        .on_update(|state| {
+
            ContainerProps::default()
+
                .border_style(state.theme.border_style)
+
                .focus_border_style(state.theme.focus_border_style)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
+
        ShortcutsProps::default()
+
            .shortcuts(&[("?", "close")])
+
            .to_boxed_any()
+
            .into()
+
    });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
+
            Key::Char('?') => Some(Message::LeavePage),
+
            _ => None,
+
        })
+
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
+
}
+

+
fn help_text() -> String {
+
    r#"# Generic keybindings
+

+
`↑,k`:      move cursor one line up
+
`↓,j:       move cursor one line down
+
`PageUp`:   move cursor one page up
+
`PageDown`: move cursor one page down
+
`Home`:     move cursor to the first line
+
`End`:      move cursor to the last line
+
`Tab`:      focus next section
+
`BackTab`:  focus previous section
+
`Esc`:      Quit / cancel
+

+
# Specific keybindings
+

+
`Enter`:    Select issue (if --mode id)
+
`Enter`:    Show issue
+
`e`:        Edit issue
+
`p`:        Toggle issue preview
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>
+
Example:    is:solved is:authored alias"#
+
        .into()
+
}
+

+
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,
+
        );
+
    }
+
}
added bin/commands/issue/list/ui.rs
@@ -0,0 +1,326 @@
+
use std::collections::HashMap;
+
use std::str::FromStr;
+
use std::vec;
+

+
use radicle::issue::{self, CloseReason};
+
use ratatui::Frame;
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Text};
+

+
use radicle_tui as tui;
+

+
use tui::ui::rm::widget;
+
use tui::ui::rm::widget::container::{
+
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
};
+
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
+
use tui::ui::rm::widget::list::{Table, TableProps};
+
use tui::ui::rm::widget::ViewProps;
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::span;
+
use tui::ui::Column;
+

+
use tui::BoxedAny;
+

+
use crate::ui::items::{IssueItem, IssueItemFilter};
+

+
use super::{Message, State};
+

+
type Widget = widget::Widget<State, Message>;
+

+
#[derive(Clone, Default)]
+
pub struct BrowserProps<'a> {
+
    /// Filtered issues.
+
    issues: Vec<IssueItem>,
+
    /// Issue statistics.
+
    stats: HashMap<String, usize>,
+
    /// Header columns
+
    header: Vec<Column<'a>>,
+
    /// Table columns
+
    columns: Vec<Column<'a>>,
+
    /// If search widget should be shown.
+
    show_search: bool,
+
    /// Current search string.
+
    search: String,
+
}
+

+
impl<'a> From<&State> for BrowserProps<'a> {
+
    fn from(state: &State) -> Self {
+
        use radicle::issue::State;
+

+
        let issues = state.browser.items();
+

+
        let mut open = 0;
+
        let mut other = 0;
+
        let mut solved = 0;
+

+
        for issue in &issues {
+
            match issue.state {
+
                State::Open => open += 1,
+
                State::Closed {
+
                    reason: CloseReason::Other,
+
                } => other += 1,
+
                State::Closed {
+
                    reason: CloseReason::Solved,
+
                } => solved += 1,
+
            }
+
        }
+

+
        let closed = solved + other;
+

+
        let stats = HashMap::from([
+
            ("Open".to_string(), open),
+
            ("Other".to_string(), other),
+
            ("Solved".to_string(), solved),
+
            ("Closed".to_string(), closed),
+
        ]);
+

+
        Self {
+
            issues,
+
            stats,
+
            header: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(5)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Opened", Constraint::Length(16)).hide_small(),
+
            ]
+
            .to_vec(),
+
            columns: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(5)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Opened", Constraint::Length(16)).hide_small(),
+
            ]
+
            .to_vec(),
+
            search: state.browser.read_search(),
+
            show_search: state.browser.is_search_shown(),
+
        }
+
    }
+
}
+

+
pub struct Browser {
+
    /// Notifications widget
+
    issues: Widget,
+
    /// Search widget
+
    search: Widget,
+
}
+

+
impl Browser {
+
    pub fn new(tx: UnboundedSender<Message>) -> Self {
+
        Self {
+
            issues: Container::default()
+
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+

+
                    HeaderProps::default()
+
                        .columns(props.header.clone())
+
                        .border_style(state.theme.border_style)
+
                        .focus_border_style(state.theme.focus_border_style)
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(
+
                    Table::<State, Message, IssueItem, 8>::default()
+
                        .to_widget(tx.clone())
+
                        .on_event(|_, s, _| {
+
                            let (selected, _) =
+
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
+
                            Some(Message::SelectIssue {
+
                                selected: Some(selected),
+
                            })
+
                        })
+
                        .on_update(|state| {
+
                            let props = BrowserProps::from(state);
+

+
                            TableProps::default()
+
                                .columns(props.columns)
+
                                .items(state.browser.items())
+
                                .selected(state.browser.selected())
+
                                .dim(state.theme.dim_no_focus)
+
                                .to_boxed_any()
+
                                .into()
+
                        }),
+
                )
+
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
+
                    let props = BrowserProps::from(state);
+

+
                    FooterProps::default()
+
                        .columns(browse_footer(&props))
+
                        .border_style(state.theme.border_style)
+
                        .focus_border_style(state.theme.focus_border_style)
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .to_widget(tx.clone())
+
                .on_update(|state| {
+
                    ContainerProps::default()
+
                        .border_style(state.theme.border_style)
+
                        .focus_border_style(state.theme.focus_border_style)
+
                        .hide_footer(BrowserProps::from(state).show_search)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
            search: TextField::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, s, _| {
+
                    Some(Message::UpdateSearch {
+
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    TextFieldProps::default()
+
                        .text(&state.browser.read_search())
+
                        .title("Search")
+
                        .inline(true)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        }
+
    }
+
}
+

+
impl View for Browser {
+
    type Message = Message;
+
    type State = State;
+

+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
+
            match key {
+
                Key::Esc => {
+
                    self.search.reset();
+
                    Some(Message::CloseSearch)
+
                }
+
                Key::Char('\n') => Some(Message::ApplySearch),
+
                _ => {
+
                    self.search.handle_event(key);
+
                    None
+
                }
+
            }
+
        } else {
+
            match key {
+
                Key::Char('/') => Some(Message::OpenSearch),
+
                _ => {
+
                    self.issues.handle_event(key);
+
                    None
+
                }
+
            }
+
        }
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        self.issues.update(state);
+
        self.search.update(state);
+
    }
+

+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
+
            let [_, search_area, _] = Layout::horizontal([
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
                Constraint::Length(1),
+
            ])
+
            .areas(search_area);
+

+
            self.issues.render(RenderProps::from(table_area), frame);
+
            self.search
+
                .render(RenderProps::from(search_area).focus(render.focus), frame);
+
        } else {
+
            self.issues.render(render, frame);
+
        }
+
    }
+
}
+

+
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
+
    let search = Line::from(vec![
+
        span::default(" Search ").cyan().dim().reversed(),
+
        span::default(" "),
+
        span::default(&props.search).gray().dim(),
+
    ]);
+

+
    let open = Line::from(vec![
+
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
        span::default(" Open").dim(),
+
    ]);
+
    let solved = Line::from(vec![
+
        span::default(&props.stats.get("Solved").unwrap_or(&0).to_string())
+
            .magenta()
+
            .dim(),
+
        span::default(" Solved").dim(),
+
    ]);
+
    let closed = Line::from(vec![
+
        span::default(&props.stats.get("Closed").unwrap_or(&0).to_string())
+
            .magenta()
+
            .dim(),
+
        span::default(" Closed").dim(),
+
    ]);
+
    let sum = Line::from(vec![
+
        span::default("Σ ").dim(),
+
        span::default(&props.issues.len().to_string()).dim(),
+
    ]);
+

+
    match IssueItemFilter::from_str(&props.search)
+
        .unwrap_or_default()
+
        .state()
+
    {
+
        Some(state) => {
+
            let block = match state {
+
                issue::State::Open => open,
+
                issue::State::Closed {
+
                    reason: issue::CloseReason::Other,
+
                } => closed,
+
                issue::State::Closed {
+
                    reason: issue::CloseReason::Solved,
+
                } => solved,
+
            };
+

+
            [
+
                Column::new(Text::from(search), Constraint::Fill(1)),
+
                Column::new(
+
                    Text::from(block.clone()),
+
                    Constraint::Min(block.width() as u16),
+
                ),
+
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
            ]
+
            .to_vec()
+
        }
+
        None => [
+
            Column::new(Text::from(search), Constraint::Fill(1)),
+
            Column::new(
+
                Text::from(open.clone()),
+
                Constraint::Min(open.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(closed.clone()),
+
                Constraint::Min(closed.width() as u16),
+
            ),
+
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
        ]
+
        .to_vec(),
+
    }
+
}
deleted bin/commands/issue/select.rs
@@ -1,693 +0,0 @@
-
#[path = "select/ui.rs"]
-
mod ui;
-

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

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

-
use ratatui::Viewport;
-
use termion::event::Key;
-

-
use ratatui::layout::Constraint;
-
use ratatui::style::Stylize;
-
use ratatui::text::Text;
-

-
use radicle::cob::thread::CommentId;
-
use radicle::git::Oid;
-
use radicle::issue::IssueId;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

-
use radicle_tui as tui;
-

-
use tui::store;
-
use tui::ui::rm::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
-
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
-
};
-
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::rm::widget::list::{Tree, TreeProps};
-
use tui::ui::rm::widget::window::{
-
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
-
};
-
use tui::ui::rm::widget::{PredefinedLayout, ToWidget, Widget};
-
use tui::ui::theme::Theme;
-
use tui::ui::Column;
-
use tui::ui::{span, BufferedValue};
-
use tui::{BoxedAny, Channel, Exit, PageStack};
-

-
use crate::cob::issue;
-
use crate::settings::{self, ThemeBundle, ThemeMode};
-
use crate::ui::items::{CommentItem, IssueItem, IssueItemFilter};
-
use crate::ui::rm::{BrowserState, IssueDetails, IssueDetailsProps};
-
use crate::ui::TerminalInfo;
-

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

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

-
type Selection = tui::Selection<IssueId>;
-

-
pub struct Context {
-
    pub profile: Profile,
-
    pub repository: Repository,
-
    pub mode: Mode,
-
    pub filter: issue::Filter,
-
}
-

-
pub struct App {
-
    context: Context,
-
    terminal_info: TerminalInfo,
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum AppPage {
-
    Browser,
-
    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 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: TextViewState,
-
}
-

-
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)]
-
pub struct HelpState {
-
    text: TextViewState,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct State {
-
    mode: Mode,
-
    pages: PageStack<AppPage>,
-
    browser: BrowserState<IssueItem, IssueItemFilter>,
-
    preview: PreviewState,
-
    section: Option<Section>,
-
    help: HelpState,
-
    theme: Theme,
-
}
-

-
impl TryFrom<(&Context, &TerminalInfo)> for State {
-
    type Error = anyhow::Error;
-

-
    fn try_from(value: (&Context, &TerminalInfo)) -> Result<Self, Self::Error> {
-
        let (context, terminal_info) = value;
-
        let settings = settings::Settings::default();
-

-
        let issues = issue::all(&context.profile, &context.repository)?;
-
        let search = BufferedValue::new(context.filter.to_string());
-
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();
-

-
        let default_bundle = ThemeBundle::default();
-
        let theme_bundle = settings.theme.active_bundle().unwrap_or(&default_bundle);
-
        let theme = match settings.theme.mode() {
-
            ThemeMode::Auto => {
-
                if terminal_info.is_dark() {
-
                    theme_bundle.dark.clone()
-
                } else {
-
                    theme_bundle.light.clone()
-
                }
-
            }
-
            ThemeMode::Light => theme_bundle.light.clone(),
-
            ThemeMode::Dark => theme_bundle.dark.clone(),
-
        };
-

-
        // Convert into UI items
-
        let mut items = vec![];
-
        for issue in issues {
-
            if let Ok(item) = IssueItem::new(&context.profile, issue.clone()) {
-
                items.push(item);
-
            }
-
        }
-
        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::build(items.clone(), filter, search),
-
            preview: PreviewState {
-
                show: true,
-
                issue: items.first().cloned(),
-
                selected_comments,
-
                comment: TextViewState::default(),
-
            },
-
            section: Some(Section::Browser),
-
            help: HelpState {
-
                text: TextViewState::default().content(help_text()),
-
            },
-
            theme,
-
        })
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Quit,
-
    Exit { operation: Option<IssueOperation> },
-
    ExitFromMode,
-
    SelectIssue { selected: Option<usize> },
-
    OpenSearch,
-
    UpdateSearch { value: String },
-
    ApplySearch,
-
    CloseSearch,
-
    TogglePreview,
-
    FocusSection { section: Option<Section> },
-
    SelectComment { selected: Option<Vec<CommentId>> },
-
    ScrollComment { state: TextViewState },
-
    OpenHelp,
-
    LeavePage,
-
    ScrollHelp { state: TextViewState },
-
}
-

-
impl store::Update<Message> for State {
-
    type Return = Selection;
-

-
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
-
        match message {
-
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.browser.selected_item().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_item().map(|issue| Exit {
-
                    value: Some(Selection {
-
                        operation,
-
                        ids: vec![issue.id],
-
                        args: vec![],
-
                    }),
-
                })
-
            }
-
            Message::SelectIssue { selected } => {
-
                self.browser.select_item(selected);
-
                self.preview.issue = self.browser.selected_item().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 { state } => {
-
                self.preview.comment = state;
-
                None
-
            }
-
            Message::OpenSearch => {
-
                self.browser.show_search();
-
                None
-
            }
-
            Message::UpdateSearch { value } => {
-
                self.browser.update_search(value);
-
                self.preview.issue = self.browser.select_first_item().cloned();
-
                None
-
            }
-
            Message::ApplySearch => {
-
                self.browser.hide_search();
-
                self.browser.apply_search();
-
                None
-
            }
-
            Message::CloseSearch => {
-
                self.browser.hide_search();
-
                self.browser.reset_search();
-

-
                self.preview.issue = self.browser.selected_item().cloned();
-
                self.preview.comment.reset_cursor();
-
                None
-
            }
-
            Message::OpenHelp => {
-
                self.pages.push(AppPage::Help);
-
                None
-
            }
-
            Message::LeavePage => {
-
                self.pages.pop();
-
                None
-
            }
-
            Message::ScrollHelp { state } => {
-
                self.help.text = state;
-
                None
-
            }
-
        }
-
    }
-
}
-

-
impl App {
-
    pub fn new(context: Context, terminal_info: TerminalInfo) -> Self {
-
        Self {
-
            context,
-
            terminal_info,
-
        }
-
    }
-

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let channel = Channel::default();
-
        let state = State::try_from((&self.context, &self.terminal_info))?;
-
        let tx = channel.tx.clone();
-

-
        let window = Window::default()
-
            .page(AppPage::Browser, browser_page(&channel))
-
            .page(AppPage::Help, help_page(&channel))
-
            .to_widget(tx.clone())
-
            .on_update(|state| {
-
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browser).clone())
-
                    .to_boxed_any()
-
                    .into()
-
            });
-

-
        tui::rm(state, window, Viewport::Inline(20), channel).await
-
    }
-
}
-

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

-
    let shortcuts = Shortcuts::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state: &State| {
-
            let shortcuts = if state.browser.is_search_shown() {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                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", "toggle preview"), ("?", "help")].to_vec()].concat()
-
            };
-

-
            ShortcutsProps::default()
-
                .shortcuts(&shortcuts)
-
                .shortcuts_keys_style(state.theme.shortcuts_keys_style)
-
                .shortcuts_action_style(state.theme.shortcuts_action_style)
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    Page::default()
-
        .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.is_search_shown())
-
                        .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| {
-
            let default = PageProps::default();
-
            let props = props
-
                .and_then(|props| props.inner_ref::<PageProps>())
-
                .unwrap_or(&default);
-

-
            if props.handle_keys {
-
                match key {
-
                    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 {
-
                None
-
            }
-
        })
-
        .on_update(|state: &State| {
-
            PageProps::default()
-
                .handle_keys(!state.browser.is_search_shown())
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

-
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(|state| {
-
            SplitContainerProps::default()
-
                .heights([Constraint::Length(5), Constraint::Min(1)])
-
                .border_style(state.theme.border_style)
-
                .focus_border_style(state.theme.focus_border_style)
-
                .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())
-
                .dim(state.theme.dim_no_focus)
-
                .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()))
-
                .dim(state.theme.dim_no_focus)
-
                .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 state = vs.and_then(|p| p.unwrap_textview()).unwrap_or_default();
-
                    Some(Message::ScrollComment { state })
-
                })
-
                .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()
-
                        .state(Some(state.preview.comment.clone().content(body)))
-
                        .footer(Some(reactions))
-
                        .show_scroll_progress(true)
-
                        .dim(state.theme.dim_no_focus)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone())
-
        .on_update(|state| {
-
            ContainerProps::default()
-
                .border_style(state.theme.border_style)
-
                .focus_border_style(state.theme.focus_border_style)
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

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

-
    let content = Container::default()
-
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
-
            HeaderProps::default()
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .to_boxed_any()
-
                .into()
-
        }))
-
        .content(
-
            TextView::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, view_state, _| {
-
                    view_state
-
                        .and_then(|tv| tv.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp { state: tvs })
-
                })
-
                .on_update(|state: &State| {
-
                    TextViewProps::default()
-
                        .state(Some(state.help.text.clone()))
-
                        .dim(state.theme.dim_no_focus)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .footer(
-
            Footer::default()
-
                .to_widget(tx.clone())
-
                .on_update(|state: &State| {
-
                    FooterProps::default()
-
                        .columns(
-
                            [
-
                                Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
-
                                    Constraint::Min(4),
-
                                ),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone())
-
        .on_update(|state| {
-
            ContainerProps::default()
-
                .border_style(state.theme.border_style)
-
                .focus_border_style(state.theme.focus_border_style)
-
                .to_boxed_any()
-
                .into()
-
        });
-

-
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
-
        ShortcutsProps::default()
-
            .shortcuts(&[("?", "close")])
-
            .to_boxed_any()
-
            .into()
-
    });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, _| match key {
-
            Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
-
            Key::Char('?') => Some(Message::LeavePage),
-
            _ => None,
-
        })
-
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
-
}
-

-
fn help_text() -> String {
-
    r#"# Generic keybindings
-

-
`↑,k`:      move cursor one line up
-
`↓,j:       move cursor one line down
-
`PageUp`:   move cursor one page up
-
`PageDown`: move cursor one page down
-
`Home`:     move cursor to the first line
-
`End`:      move cursor to the last line
-
`Tab`:      focus next section
-
`BackTab`:  focus previous section
-
`Esc`:      Quit / cancel
-

-
# Specific keybindings
-

-
`Enter`:    Select issue (if --mode id)
-
`Enter`:    Show issue
-
`e`:        Edit issue
-
`p`:        Toggle issue preview
-
`/`:        Search
-
`?`:        Show help
-

-
# Searching
-

-
Pattern:    is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>
-
Example:    is:solved is:authored alias"#
-
        .into()
-
}
-

-
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,
-
        );
-
    }
-
}
deleted bin/commands/issue/select/ui.rs
@@ -1,326 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-
use std::vec;
-

-
use radicle::issue::{self, CloseReason};
-
use ratatui::Frame;
-
use tokio::sync::mpsc::UnboundedSender;
-

-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Text};
-

-
use radicle_tui as tui;
-

-
use tui::ui::rm::widget;
-
use tui::ui::rm::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
-
};
-
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
-
use tui::ui::rm::widget::list::{Table, TableProps};
-
use tui::ui::rm::widget::ViewProps;
-
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
-
use tui::ui::span;
-
use tui::ui::Column;
-

-
use tui::BoxedAny;
-

-
use crate::ui::items::{IssueItem, IssueItemFilter};
-

-
use super::{Message, State};
-

-
type Widget = widget::Widget<State, Message>;
-

-
#[derive(Clone, Default)]
-
pub struct BrowserProps<'a> {
-
    /// Filtered issues.
-
    issues: Vec<IssueItem>,
-
    /// Issue statistics.
-
    stats: HashMap<String, usize>,
-
    /// Header columns
-
    header: Vec<Column<'a>>,
-
    /// Table columns
-
    columns: Vec<Column<'a>>,
-
    /// If search widget should be shown.
-
    show_search: bool,
-
    /// Current search string.
-
    search: String,
-
}
-

-
impl<'a> From<&State> for BrowserProps<'a> {
-
    fn from(state: &State) -> Self {
-
        use radicle::issue::State;
-

-
        let issues = state.browser.items();
-

-
        let mut open = 0;
-
        let mut other = 0;
-
        let mut solved = 0;
-

-
        for issue in &issues {
-
            match issue.state {
-
                State::Open => open += 1,
-
                State::Closed {
-
                    reason: CloseReason::Other,
-
                } => other += 1,
-
                State::Closed {
-
                    reason: CloseReason::Solved,
-
                } => solved += 1,
-
            }
-
        }
-

-
        let closed = solved + other;
-

-
        let stats = HashMap::from([
-
            ("Open".to_string(), open),
-
            ("Other".to_string(), other),
-
            ("Solved".to_string(), solved),
-
            ("Closed".to_string(), closed),
-
        ]);
-

-
        Self {
-
            issues,
-
            stats,
-
            header: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(5)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Opened", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            columns: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(5)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
-
                Column::new("Opened", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            search: state.browser.read_search(),
-
            show_search: state.browser.is_search_shown(),
-
        }
-
    }
-
}
-

-
pub struct Browser {
-
    /// Notifications widget
-
    issues: Widget,
-
    /// Search widget
-
    search: Widget,
-
}
-

-
impl Browser {
-
    pub fn new(tx: UnboundedSender<Message>) -> Self {
-
        Self {
-
            issues: Container::default()
-
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-

-
                    HeaderProps::default()
-
                        .columns(props.header.clone())
-
                        .border_style(state.theme.border_style)
-
                        .focus_border_style(state.theme.focus_border_style)
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(
-
                    Table::<State, Message, IssueItem, 8>::default()
-
                        .to_widget(tx.clone())
-
                        .on_event(|_, s, _| {
-
                            let (selected, _) =
-
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::SelectIssue {
-
                                selected: Some(selected),
-
                            })
-
                        })
-
                        .on_update(|state| {
-
                            let props = BrowserProps::from(state);
-

-
                            TableProps::default()
-
                                .columns(props.columns)
-
                                .items(state.browser.items())
-
                                .selected(state.browser.selected())
-
                                .dim(state.theme.dim_no_focus)
-
                                .to_boxed_any()
-
                                .into()
-
                        }),
-
                )
-
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
-
                    let props = BrowserProps::from(state);
-

-
                    FooterProps::default()
-
                        .columns(browse_footer(&props))
-
                        .border_style(state.theme.border_style)
-
                        .focus_border_style(state.theme.focus_border_style)
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    ContainerProps::default()
-
                        .border_style(state.theme.border_style)
-
                        .focus_border_style(state.theme.focus_border_style)
-
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
            search: TextField::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    Some(Message::UpdateSearch {
-
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.read_search())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        }
-
    }
-
}
-

-
impl View for Browser {
-
    type Message = Message;
-
    type State = State;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            match key {
-
                Key::Esc => {
-
                    self.search.reset();
-
                    Some(Message::CloseSearch)
-
                }
-
                Key::Char('\n') => Some(Message::ApplySearch),
-
                _ => {
-
                    self.search.handle_event(key);
-
                    None
-
                }
-
            }
-
        } else {
-
            match key {
-
                Key::Char('/') => Some(Message::OpenSearch),
-
                _ => {
-
                    self.issues.handle_event(key);
-
                    None
-
                }
-
            }
-
        }
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        self.issues.update(state);
-
        self.search.update(state);
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
-
            let [_, search_area, _] = Layout::horizontal([
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
                Constraint::Length(1),
-
            ])
-
            .areas(search_area);
-

-
            self.issues.render(RenderProps::from(table_area), frame);
-
            self.search
-
                .render(RenderProps::from(search_area).focus(render.focus), frame);
-
        } else {
-
            self.issues.render(render, frame);
-
        }
-
    }
-
}
-

-
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
-
    let search = Line::from(vec![
-
        span::default(" Search ").cyan().dim().reversed(),
-
        span::default(" "),
-
        span::default(&props.search).gray().dim(),
-
    ]);
-

-
    let open = Line::from(vec![
-
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Open").dim(),
-
    ]);
-
    let solved = Line::from(vec![
-
        span::default(&props.stats.get("Solved").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Solved").dim(),
-
    ]);
-
    let closed = Line::from(vec![
-
        span::default(&props.stats.get("Closed").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Closed").dim(),
-
    ]);
-
    let sum = Line::from(vec![
-
        span::default("Σ ").dim(),
-
        span::default(&props.issues.len().to_string()).dim(),
-
    ]);
-

-
    match IssueItemFilter::from_str(&props.search)
-
        .unwrap_or_default()
-
        .state()
-
    {
-
        Some(state) => {
-
            let block = match state {
-
                issue::State::Open => open,
-
                issue::State::Closed {
-
                    reason: issue::CloseReason::Other,
-
                } => closed,
-
                issue::State::Closed {
-
                    reason: issue::CloseReason::Solved,
-
                } => solved,
-
            };
-

-
            [
-
                Column::new(Text::from(search), Constraint::Fill(1)),
-
                Column::new(
-
                    Text::from(block.clone()),
-
                    Constraint::Min(block.width() as u16),
-
                ),
-
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
            ]
-
            .to_vec()
-
        }
-
        None => [
-
            Column::new(Text::from(search), Constraint::Fill(1)),
-
            Column::new(
-
                Text::from(open.clone()),
-
                Constraint::Min(open.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(closed.clone()),
-
                Constraint::Min(closed.width() as u16),
-
            ),
-
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
        ]
-
        .to_vec(),
-
    }
-
}