Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
patch-list: Add implementation
Erik Kundt committed 2 years ago
commit 9d676ce50eb72b07e6276baba4f69eafcaedce4b
parent b0fc9fab28b9d98063b18f037c924a21700f8833
12 files changed +750 -43
modified Cargo.toml
@@ -25,3 +25,7 @@ thiserror = { version = "1" }
tuirealm = { version = "1.9.0", default-features = false, features = [ "with-termion" ] }
tui-realm-stdlib = { version = "1.2.0", default-features = false, features = [ "with-termion" ] }
tui-realm-textarea = { git = "https://github.com/erak/tui-realm-textarea.git", default-features = false, features = [ "with-termion", "clipboard" ] }
+

+
# tuirealm = { version = "1.8.0", default-features = false, features = [ "with-crossterm" ] }
+
# tui-realm-stdlib = { version = "1.2.0", default-features = false, features = [ "with-crossterm" ] }
+
# tui-realm-textarea = { git = "https://github.com/erak/tui-realm-textarea.git", default-features = false, features = [ "with-crossterm", "clipboard" ] }
modified bin/commands/issue/suite.rs
@@ -18,8 +18,7 @@ use tui::cob;
use tui::context::Context;
use tui::ui::subscription;
use tui::ui::theme::{self, Theme};
-
use tui::PageStack;
-
use tui::Tui;
+
use tui::{Exit, PageStack, Tui};

use page::{IssuePage, ListPage};

@@ -341,7 +340,10 @@ impl Tui<Cid, Message> for App {
        }
    }

-
    fn quit(&self) -> bool {
-
        self.quit
+
    fn exit(&self) -> Option<Exit> {
+
        if self.quit {
+
            return Some(Exit { value: None });
+
        }
+
        None
    }
}
modified bin/commands/patch.rs
@@ -1,14 +1,18 @@
#[path = "patch/suite.rs"]
mod suite;
+
#[path = "patch/list.rs"]
+
mod list;

use std::ffi::OsString;

use anyhow::anyhow;

+
use radicle_tui::{context, log, Window};
+

use crate::terminal;
use crate::terminal::args::{Args, Error, Help};

-
#[allow(dead_code)]
+
pub const FPS: u64 = 60;
pub const HELP: Help = Help {
    name: "patch",
    description: "Terminal interfaces for patches",
@@ -24,19 +28,17 @@ General options
"#,
};

-
#[allow(dead_code)]
pub struct Options {
    op: Operation,
}

pub enum Operation {
-
    Suite,
+
    List,
}

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

impl Args for Options {
@@ -44,7 +46,7 @@ impl Args for Options {
        use lexopt::prelude::*;

        let mut parser = lexopt::Parser::from_args(args);
-
        let op: Option<OperationName> = None;
+
        let mut op: Option<OperationName> = None;

        #[allow(clippy::never_loop)]
        while let Some(arg) = parser.next()? {
@@ -52,18 +54,38 @@ impl Args for Options {
                Long("help") | Short('h') => {
                    return Err(Error::Help.into());
                }
+
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
+
                    "list" => op = Some(OperationName::List),
+
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
+
                },
                _ => return Err(anyhow!(arg.unexpected())),
            }
        }

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Suite => Operation::Suite,
+
        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
+
            OperationName::List => Operation::List,
        };
        Ok((Options { op }, vec![]))
    }
}

-
#[allow(dead_code)]
-
pub fn run(_options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
+
    let (_, id) = radicle::rad::cwd()
+
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+

+
    let context = context::Context::new(id)?;
+

+
    match options.op {
+
        Operation::List => {
+
            log::enable("patch", "list", context.profile())?;
+

+
            let patch_id = Window::default()
+
                .run(&mut list::App::new(context), 1000 / FPS)?
+
                .ok_or_else(|| anyhow!("expected patch id"))?;
+

+
            eprint!("{patch_id}");
+
        }
+
    }
+

    Ok(())
}
added bin/commands/patch/list.rs
@@ -0,0 +1,238 @@
+
#[path = "list/event.rs"]
+
mod event;
+
#[path = "list/page.rs"]
+
mod page;
+
#[path = "list/ui.rs"]
+
mod ui;
+

+
use std::hash::Hash;
+

+
use anyhow::Result;
+

+
use radicle::cob::patch::PatchId;
+

+
use tui::Exit;
+
use tuirealm::application::PollStrategy;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use radicle_tui::context::Context;
+
use radicle_tui::ui::subscription;
+
use radicle_tui::ui::theme::{self, Theme};
+
use radicle_tui::PageStack;
+
use radicle_tui::Tui;
+

+
use page::ListView;
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum ListCid {
+
    Header,
+
    PatchBrowser,
+
    Context,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    List(ListCid),
+
    #[default]
+
    GlobalListener,
+
    Popup,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum PopupMessage {
+
    Info(String),
+
    Warning(String),
+
    Error(String),
+
    Hide,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    Popup(PopupMessage),
+
    #[default]
+
    Tick,
+
    Quit(Option<PatchId>),
+
    Batch(Vec<Message>),
+
}
+

+
#[allow(dead_code)]
+
pub struct App {
+
    context: Context,
+
    pages: PageStack<Cid, Message>,
+
    theme: Theme,
+
    quit: bool,
+
    patch_id: Option<PatchId>,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
impl App {
+
    pub fn new(context: Context) -> Self {
+
        Self {
+
            context,
+
            pages: PageStack::default(),
+
            theme: theme::default_dark(),
+
            quit: false,
+
            patch_id: None,
+
        }
+
    }
+

+
    fn view_list(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let home = Box::new(ListView::new(theme.clone()));
+
        self.pages.push(home, app, &self.context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn process(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        let theme = theme::default_dark();
+
        match message {
+
            Message::Batch(messages) => {
+
                let mut results = vec![];
+
                for message in messages {
+
                    if let Some(result) = self.process(app, message)? {
+
                        results.push(result);
+
                    }
+
                }
+
                match results.len() {
+
                    0 => Ok(None),
+
                    1 => Ok(Some(results[0].to_owned())),
+
                    _ => Ok(Some(Message::Batch(results))),
+
                }
+
            }
+
            Message::Popup(PopupMessage::Info(info)) => {
+
                self.show_info_popup(app, &theme, &info)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Warning(warning)) => {
+
                self.show_warning_popup(app, &theme, &warning)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Error(error)) => {
+
                self.show_error_popup(app, &theme, &error)?;
+
                Ok(None)
+
            }
+
            Message::Popup(PopupMessage::Hide) => {
+
                self.hide_popup(app)?;
+
                Ok(None)
+
            }
+
            Message::Quit(id) => {
+
                self.quit = true;
+
                self.patch_id = id;
+
                Ok(None)
+
            }
+
            _ => self
+
                .pages
+
                .peek_mut()?
+
                .update(app, &self.context, &theme, message),
+
        }
+
    }
+

+
    fn show_info_popup(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
        message: &str,
+
    ) -> Result<()> {
+
        let popup = tui::ui::info(theme, message);
+
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
+
        app.active(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn show_warning_popup(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
        message: &str,
+
    ) -> Result<()> {
+
        let popup = tui::ui::warning(theme, message);
+
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
+
        app.active(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn show_error_popup(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
        message: &str,
+
    ) -> Result<()> {
+
        let popup = tui::ui::error(theme, message);
+
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
+
        app.active(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+

+
    fn hide_popup(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.blur()?;
+
        app.umount(&Cid::Popup)?;
+

+
        Ok(())
+
    }
+
}
+

+
impl Tui<Cid, Message> for App {
+
    fn init(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        self.view_list(app, &self.theme.clone())?;
+

+
        // Add global key listener and subscribe to key events
+
        let global = tui::ui::global_listener().to_boxed();
+
        app.mount(
+
            Cid::GlobalListener,
+
            global,
+
            vec![Sub::new(subscription::global_clause(), SubClause::Always)],
+
        )?;
+

+
        Ok(())
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        if let Ok(page) = self.pages.peek_mut() {
+
            page.view(app, frame);
+
        }
+

+
        if app.mounted(&Cid::Popup) {
+
            app.view(&Cid::Popup, frame, frame.size());
+
        }
+
    }
+

+
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
+
        match app.tick(PollStrategy::Once) {
+
            Ok(messages) if !messages.is_empty() => {
+
                for message in messages {
+
                    let mut msg = Some(message);
+
                    while msg.is_some() {
+
                        msg = self.process(app, msg.unwrap())?;
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn exit(&self) -> Option<Exit> {
+
        if self.quit {
+
            return Some(Exit {
+
                value: self.patch_id.map(|id| format!("{id}")),
+
            });
+
        }
+
        None
+
    }
+
}
added bin/commands/patch/list/event.rs
@@ -0,0 +1,107 @@
+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
+
use tuirealm::event::{Event, Key, KeyEvent};
+
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
+

+
use radicle_tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer, Popup};
+
use radicle_tui::ui::widget::context::{ContextBar, Shortcuts};
+
use radicle_tui::ui::widget::list::PropertyList;
+

+
use radicle_tui::ui::widget::Widget;
+

+
use super::{ui, Message, PopupMessage};
+

+
/// Since the framework does not know the type of messages that are being
+
/// passed around in the app, the following handlers need to be implemented for
+
/// each component used.
+
///
+
/// TODO: should handle `Event::WindowResize`, which is not emitted by `termion`.
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('q'),
+
                ..
+
            }) => Some(Message::Quit(None)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::PatchBrowser> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => {
+
                let result = self.perform(Cmd::Submit);
+
                match result {
+
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        Some(Message::Quit(Some(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Popup> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Popup(PopupMessage::Hide))
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<LabeledContainer> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<PropertyList> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ContextBar> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Shortcuts> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
added bin/commands/patch/list/page.rs
@@ -0,0 +1,163 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use tuirealm::{Frame, NoUserEvent, State, StateValue, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::context::Context;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::context::{Progress, Shortcuts};
+
use tui::ui::widget::Widget;
+
use tui::ui::{layout, subscription};
+
use tui::ViewPage;
+

+
use super::{ui, Application, Cid, ListCid, Message};
+

+
///
+
/// Home
+
///
+
pub struct ListView {
+
    active_component: ListCid,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
+
}
+

+
impl ListView {
+
    pub fn new(theme: Theme) -> Self {
+
        let shortcuts = Self::build_shortcuts(&theme);
+
        Self {
+
            active_component: ListCid::PatchBrowser,
+
            shortcuts,
+
        }
+
    }
+

+
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::PatchBrowser,
+
            tui::ui::shortcuts(
+
                theme,
+
                vec![
+
                    tui::ui::shortcut(theme, "tab", "section"),
+
                    tui::ui::shortcut(theme, "↑/↓", "navigate"),
+
                    tui::ui::shortcut(theme, "enter", "show"),
+
                    tui::ui::shortcut(theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let state = app.state(&Cid::List(ListCid::PatchBrowser))?;
+
        let progress = match state {
+
            State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => {
+
                Progress::Step(step.saturating_add(1), total)
+
            }
+
            _ => Progress::None,
+
        };
+
        let context = ui::browse_context(context, theme, progress);
+

+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::List(ListCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for ListView {
+
    fn mount(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = ui::list_navigation(theme);
+
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
+
        let patch_browser = ui::patches(context, theme, None).to_boxed();
+

+
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
+
        app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
+

+
        app.active(&Cid::List(self.active_component.clone()))?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.umount(&Cid::List(ListCid::Header))?;
+
        app.umount(&Cid::List(ListCid::PatchBrowser))?;
+
        app.umount(&Cid::List(ListCid::Context))?;
+
        app.umount(&Cid::List(ListCid::Shortcuts))?;
+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        _message: Message,
+
    ) -> Result<Option<Message>> {
+
        self.update_context(app, context, theme)?;
+

+
        Ok(None)
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        let area = frame.size();
+
        let shortcuts_h = 1u16;
+
        let layout = layout::default_page(area, shortcuts_h);
+

+
        app.view(&Cid::List(ListCid::Header), frame, layout.navigation);
+
        app.view(
+
            &Cid::List(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+

+
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
+
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

+
    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.subscribe(
+
            &Cid::List(ListCid::Header),
+
            Sub::new(subscription::navigation_clause(), SubClause::Always),
+
        )?;
+

+
        Ok(())
+
    }
+

+
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.unsubscribe(
+
            &Cid::List(ListCid::Header),
+
            subscription::navigation_clause(),
+
        )?;
+

+
        Ok(())
+
    }
+
}
added bin/commands/patch/list/ui.rs
@@ -0,0 +1,140 @@
+
use radicle::cob::patch::{Patch, PatchId};
+

+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
+

+
use radicle_tui as tui;
+

+
use tui::context::Context;
+
use tui::ui::cob::PatchItem;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::{Widget, WidgetComponent};
+

+
use tui::ui::widget::container::Tabs;
+
use tui::ui::widget::context::{ContextBar, Progress};
+
use tui::ui::widget::list::{ColumnWidth, Table};
+

+
pub struct PatchBrowser {
+
    items: Vec<PatchItem>,
+
    table: Widget<Table<PatchItem, 8>>,
+
}
+

+
impl PatchBrowser {
+
    pub fn new(context: &Context, theme: &Theme, selected: Option<(PatchId, Patch)>) -> Self {
+
        let header = [
+
            tui::ui::label(" ● "),
+
            tui::ui::label("ID"),
+
            tui::ui::label("Title"),
+
            tui::ui::label("Author"),
+
            tui::ui::label("Head"),
+
            tui::ui::label("+"),
+
            tui::ui::label("-"),
+
            tui::ui::label("Updated"),
+
        ];
+

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

+
        let repo = context.repository();
+
        let mut items = vec![];
+

+
        for (id, patch) in context.patches() {
+
            if let Ok(item) = PatchItem::try_from((context.profile(), repo, *id, patch.clone())) {
+
                items.push(item);
+
            }
+
        }
+

+
        items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
+
        items.sort_by(|a, b| a.state().cmp(b.state()));
+

+
        let selected = match selected {
+
            Some((id, patch)) => {
+
                Some(PatchItem::try_from((context.profile(), repo, id, patch)).unwrap())
+
            }
+
            _ => items.first().cloned(),
+
        };
+

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

+
        Self { items, table }
+
    }
+

+
    pub fn items(&self) -> &Vec<PatchItem> {
+
        &self.items
+
    }
+
}
+

+
impl WidgetComponent for PatchBrowser {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        self.table.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.table.view(frame, area);
+
    }
+

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

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

+
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
+
    tui::ui::tabs(
+
        theme,
+
        vec![tui::ui::reversable_label("Patches").foreground(theme.colors.tabs_highlighted_fg)],
+
    )
+
}
+

+
pub fn patches(
+
    context: &Context,
+
    theme: &Theme,
+
    selected: Option<(PatchId, Patch)>,
+
) -> Widget<PatchBrowser> {
+
    Widget::new(PatchBrowser::new(context, theme, selected))
+
}
+

+
pub fn browse_context(context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
+
    use radicle::cob::patch::State;
+

+
    let patches = context.patches();
+
    let mut draft = 0;
+
    let mut open = 0;
+
    let mut archived = 0;
+
    let mut merged = 0;
+

+
    for (_, patch) in patches {
+
        match patch.state() {
+
            State::Draft => draft += 1,
+
            State::Open { conflicts: _ } => open += 1,
+
            State::Archived => archived += 1,
+
            State::Merged {
+
                commit: _,
+
                revision: _,
+
            } => merged += 1,
+
        }
+
    }
+

+
    tui::ui::widget::context::bar(
+
        theme,
+
        "Browse",
+
        "",
+
        "",
+
        &format!("{draft} draft | {open} open | {archived} archived | {merged} merged"),
+
        &progress.to_string(),
+
    )
+
}
modified bin/commands/patch/suite.rs
@@ -16,12 +16,11 @@ use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};

use radicle_tui as tui;

-
use radicle_tui::cob;
-
use radicle_tui::context::Context;
-
use radicle_tui::ui::subscription;
-
use radicle_tui::ui::theme::{self, Theme};
-
use radicle_tui::PageStack;
-
use radicle_tui::Tui;
+
use tui::cob;
+
use tui::context::Context;
+
use tui::ui::subscription;
+
use tui::ui::theme::{self, Theme};
+
use tui::{Exit, PageStack, Tui};

use page::{ListView, PatchView};

@@ -272,7 +271,10 @@ impl Tui<Cid, Message> for App {
        }
    }

-
    fn quit(&self) -> bool {
-
        self.quit
+
    fn exit(&self) -> Option<Exit> {
+
        if self.quit {
+
            return Some(Exit { value: None });
+
        }
+
        None
    }
}
modified bin/main.rs
@@ -95,8 +95,17 @@ fn run(command: Command) -> Result<(), Option<anyhow::Error>> {
    Ok(())
}

-
fn run_other(exe: &str, _args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
-
    Err(Some(anyhow!(
-
        "`{exe}` is not a command. See `rad-tui --help` for a list of commands.",
-
    )))
+
fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
+
    match exe {
+
        "patch" => {
+
            terminal::run_command_args::<tui_patch::Options, _>(
+
                tui_patch::HELP,
+
                tui_patch::run,
+
                args.to_vec(),
+
            );
+
        }
+
        other => Err(Some(anyhow!(
+
            "`{other}` is not a command. See `rad-tui --help` for a list of commands.",
+
        ))),
+
    }
}
modified bin/terminal.rs
@@ -46,7 +46,6 @@ where
    }
}

-
#[allow(dead_code)]
pub fn run_command_args<A, C>(help: Help, cmd: C, args: Vec<OsString>) -> !
where
    A: Args,
modified src/context.rs
@@ -1,3 +1,5 @@
+
use std::fmt::Display;
+

use radicle_term as term;

use radicle::cob::issue::{Issue, IssueId};
@@ -6,16 +8,31 @@ use radicle::crypto::ssh::keystore::{Keystore, MemorySigner};
use radicle::crypto::Signer;
use radicle::prelude::{Id, Project};
use radicle::profile::env::RAD_PASSPHRASE;
-
use radicle::storage::ReadRepository;
-
use radicle::Profile;
-

use radicle::storage::git::Repository;
-
use radicle::storage::ReadStorage;
+
use radicle::storage::{ReadRepository, ReadStorage};
+

+
use radicle::Profile;

use term::{passphrase, spinner, Passphrase};

use inquire::validator;

+
/// Git revision parameter. Supports extended SHA-1 syntax.
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct Rev(String);
+

+
impl From<String> for Rev {
+
    fn from(value: String) -> Self {
+
        Rev(value)
+
    }
+
}
+

+
impl Display for Rev {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, "{}", self.0)
+
    }
+
}
+

pub struct Context {
    profile: Profile,
    id: Id,
@@ -32,12 +49,11 @@ impl Context {
        let signer = signer(&profile)?;

        let repository = profile.storage.repository(id).unwrap();
-
        let doc = repository.identity_doc()?;
-
        let project = doc.project()?;
-

        let issues = crate::cob::issue::all(&repository).unwrap_or_default();
        let patches = crate::cob::patch::all(&repository).unwrap_or_default();

+
        let project = repository.identity_doc()?.project()?;
+

        Ok(Self {
            id,
            profile,
modified src/lib.rs
@@ -37,8 +37,13 @@ where
    /// Should draw the application to a frame.
    fn view(&mut self, app: &mut Application<Id, Message, NoUserEvent>, frame: &mut Frame);

-
    /// Should return true if the application is requested to quit.
-
    fn quit(&self) -> bool;
+
    /// Should return `Some` if the application is requested to quit.
+
    fn exit(&self) -> Option<Exit>;
+
}
+

+
/// An optional return value.
+
pub struct Exit {
+
    pub value: Option<String>,
}

/// A tui-window using the cross-platform Terminal helper provided
@@ -59,9 +64,9 @@ impl Window {
    /// Creates a tui-window using the default cross-platform Terminal
    /// helper and panics if its creation fails.
    pub fn new() -> Self {
-
        Self {
-
            terminal: TerminalBridge::new().expect("Cannot create terminal bridge"),
-
        }
+
        let terminal = TerminalBridge::new().expect("Cannot create terminal bridge");
+

+
        Self { terminal }
    }

    /// Runs this tui-window with the tui-application given and performs the
@@ -72,7 +77,7 @@ impl Window {
    ///    - update application state
    ///    - redraw view
    /// 3. Leave alternative terminal screen
-
    pub fn run<T, Id, Message>(&mut self, tui: &mut T, interval: u64) -> Result<()>
+
    pub fn run<T, Id, Message>(&mut self, tui: &mut T, interval: u64) -> Result<Option<String>>
    where
        T: Tui<Id, Message>,
        Id: Eq + PartialEq + Clone + Hash,
@@ -86,7 +91,7 @@ impl Window {
        );
        tui.init(&mut app)?;

-
        while !tui.quit() {
+
        while tui.exit().is_none() {
            if update || resize {
                self.terminal
                    .raw_mut()
@@ -98,7 +103,7 @@ impl Window {
            size = self.terminal.raw().size()?;
        }

-
        Ok(())
+
        Ok(tui.exit().unwrap().value)
    }
}