Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
lib: Build single binary
Erik Kundt committed 2 years ago
commit 824421177a227e5a3887cc358096923030f4525a
parent 668793a2ba0ffed76ca69be94effdfa772478738
26 files changed +3611 -3612
modified Cargo.toml
@@ -7,12 +7,8 @@ edition = "2021"
build = "build.rs"

[[bin]]
-
name = "rad-issue-tui"
-
path = "src/issue/main.rs"
-

-
[[bin]]
-
name = "rad-patch-tui"
-
path = "src/patch/main.rs"
+
name = "radicle-tui"
+
path = "bin/main.rs"

[dependencies]
anyhow = { version = "1" }
added bin/commands/issue.rs
@@ -0,0 +1,397 @@
+
pub mod event;
+
pub mod page;
+
pub mod subscription;
+

+
use anyhow::Result;
+

+
use radicle::cob::issue::IssueId;
+
use radicle::cob::patch::PatchId;
+
use radicle::identity::{Id, Project};
+
use radicle::prelude::Signer;
+
use radicle::profile::Profile;
+

+
use radicle_tui::ui::widget;
+
use tuirealm::application::PollStrategy;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

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

+
use page::{HomeView, PatchView};
+

+
use self::page::{IssuePage, PageStack};
+

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

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

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum IssueCid {
+
    Header,
+
    List,
+
    Details,
+
    Context,
+
    Form,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    Home(HomeCid),
+
    Issue(IssueCid),
+
    Patch(PatchCid),
+
    GlobalListener,
+
    Popup,
+
}
+

+
/// Messages handled by this application.
+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum HomeMessage {
+
    RefreshIssues(Option<IssueId>),
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum IssueCobMessage {
+
    Create {
+
        title: String,
+
        tags: String,
+
        assignees: String,
+
        description: String,
+
    },
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum IssueMessage {
+
    Show(Option<IssueId>),
+
    Changed(IssueId),
+
    Focus(IssueCid),
+
    Created(IssueId),
+
    Cob(IssueCobMessage),
+
    OpenForm,
+
    HideForm,
+
    Leave(Option<IssueId>),
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum PatchMessage {
+
    Show(PatchId),
+
    Leave,
+
}
+

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

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    Home(HomeMessage),
+
    Issue(IssueMessage),
+
    Patch(PatchMessage),
+
    NavigationChanged(u16),
+
    FormSubmitted(String),
+
    Popup(PopupMessage),
+
    Tick,
+
    Quit,
+
    Batch(Vec<Message>),
+
}
+

+
#[allow(dead_code)]
+
pub struct App {
+
    context: Context,
+
    pages: PageStack,
+
    theme: Theme,
+
    quit: bool,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
impl App {
+
    pub fn new(profile: Profile, id: Id, project: Project, signer: Box<dyn Signer>) -> Self {
+
        Self {
+
            context: Context::new(profile, id, project, signer),
+
            pages: PageStack::default(),
+
            theme: theme::default_dark(),
+
            quit: false,
+
        }
+
    }
+

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

+
        Ok(())
+
    }
+

+
    fn view_patch(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        id: PatchId,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let repo = self.context.repository();
+

+
        if let Some(patch) = cob::patch::find(repo, &id)? {
+
            let view = Box::new(PatchView::new(theme.clone(), (id, patch)));
+
            self.pages.push(view, app, &self.context, theme)?;
+

+
            Ok(())
+
        } else {
+
            Err(anyhow::anyhow!(
+
                "Could not mount 'page::PatchView'. Patch not found."
+
            ))
+
        }
+
    }
+

+
    fn view_issue(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        id: Option<IssueId>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let repo = self.context.repository();
+
        match id {
+
            Some(id) => {
+
                if let Some(issue) = cob::issue::find(repo, &id)? {
+
                    let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue))));
+
                    self.pages.push(view, app, &self.context, theme)?;
+

+
                    Ok(())
+
                } else {
+
                    Err(anyhow::anyhow!(
+
                        "Could not mount 'page::IssueView'. Issue not found."
+
                    ))
+
                }
+
            }
+
            None => {
+
                let view = Box::new(IssuePage::new(&self.context, theme, None));
+
                self.pages.push(view, 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::Issue(IssueMessage::Cob(IssueCobMessage::Create {
+
                title,
+
                tags,
+
                assignees,
+
                description,
+
            })) => match self.create_issue(title, description, tags, assignees) {
+
                Ok(id) => {
+
                    self.context.reload();
+

+
                    Ok(Some(Message::Batch(vec![
+
                        Message::Issue(IssueMessage::HideForm),
+
                        Message::Issue(IssueMessage::Created(id)),
+
                    ])))
+
                }
+
                Err(err) => {
+
                    let error = format!("{:?}", err);
+
                    self.show_error_popup(app, &theme, &error)?;
+

+
                    Ok(None)
+
                }
+
            },
+
            Message::Issue(IssueMessage::Show(id)) => {
+
                self.view_issue(app, id, &theme)?;
+
                Ok(None)
+
            }
+
            Message::Issue(IssueMessage::Leave(id)) => {
+
                self.pages.pop(app)?;
+
                Ok(Some(Message::Home(HomeMessage::RefreshIssues(id))))
+
            }
+
            Message::Patch(PatchMessage::Show(id)) => {
+
                self.view_patch(app, id, &theme)?;
+
                Ok(None)
+
            }
+
            Message::Patch(PatchMessage::Leave) => {
+
                self.pages.pop(app)?;
+
                Ok(None)
+
            }
+
            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 => {
+
                self.quit = true;
+
                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 = widget::common::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 = widget::common::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 = widget::common::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(())
+
    }
+

+
    fn create_issue(
+
        &mut self,
+
        title: String,
+
        description: String,
+
        labels: String,
+
        assignees: String,
+
    ) -> Result<IssueId> {
+
        let repository = self.context.repository();
+
        let signer = self.context.signer();
+

+
        let labels = cob::parse_labels(labels)?;
+
        let assignees = cob::parse_assignees(assignees)?;
+

+
        cob::issue::create(
+
            repository,
+
            signer,
+
            title,
+
            description,
+
            labels.as_slice(),
+
            assignees.as_slice(),
+
        )
+
    }
+
}
+

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

+
        // Add global key listener and subscribe to key events
+
        let global = ui::widget::common::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 quit(&self) -> bool {
+
        self.quit
+
    }
+
}
added bin/commands/issue/app.rs
@@ -0,0 +1,343 @@
+
mod event;
+
mod page;
+
mod ui;
+

+
use anyhow::Result;
+

+
use radicle::cob::issue::IssueId;
+

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

+
use radicle_tui as tui;
+

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

+
use page::{IssuePage, ListPage};
+

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

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum IssueCid {
+
    Header,
+
    List,
+
    Details,
+
    Context,
+
    Form,
+
    Shortcuts,
+
}
+

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

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum IssueCobMessage {
+
    Create {
+
        title: String,
+
        tags: String,
+
        assignees: String,
+
        description: String,
+
    },
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum IssueMessage {
+
    Show(Option<IssueId>),
+
    Changed(IssueId),
+
    Focus(IssueCid),
+
    Created(IssueId),
+
    Cob(IssueCobMessage),
+
    Reload(Option<IssueId>),
+
    OpenForm,
+
    HideForm,
+
    Leave(Option<IssueId>),
+
}
+

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

+
#[derive(Clone, Debug, Default, Eq, PartialEq)]
+
pub enum Message {
+
    Issue(IssueMessage),
+
    NavigationChanged(u16),
+
    FormSubmitted(String),
+
    Popup(PopupMessage),
+
    #[default]
+
    Tick,
+
    Quit,
+
    Batch(Vec<Message>),
+
}
+

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

+
/// 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,
+
        }
+
    }
+

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

+
        Ok(())
+
    }
+

+
    fn view_issue(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        id: Option<IssueId>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let repo = self.context.repository();
+
        match id {
+
            Some(id) => {
+
                if let Some(issue) = cob::issue::find(repo, &id)? {
+
                    let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue))));
+
                    self.pages.push(view, app, &self.context, theme)?;
+

+
                    Ok(())
+
                } else {
+
                    Err(anyhow::anyhow!(
+
                        "Could not mount 'page::IssueView'. Issue not found."
+
                    ))
+
                }
+
            }
+
            None => {
+
                let view = Box::new(IssuePage::new(&self.context, theme, None));
+
                self.pages.push(view, 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::Issue(IssueMessage::Cob(IssueCobMessage::Create {
+
                title,
+
                tags,
+
                assignees,
+
                description,
+
            })) => match self.create_issue(title, description, tags, assignees) {
+
                Ok(id) => {
+
                    self.context.reload();
+

+
                    Ok(Some(Message::Batch(vec![
+
                        Message::Issue(IssueMessage::HideForm),
+
                        Message::Issue(IssueMessage::Created(id)),
+
                    ])))
+
                }
+
                Err(err) => {
+
                    let error = format!("{:?}", err);
+
                    self.show_error_popup(app, &theme, &error)?;
+

+
                    Ok(None)
+
                }
+
            },
+
            Message::Issue(IssueMessage::Show(id)) => {
+
                self.view_issue(app, id, &theme)?;
+
                Ok(None)
+
            }
+
            Message::Issue(IssueMessage::Leave(id)) => {
+
                self.pages.pop(app)?;
+
                Ok(Some(Message::Issue(IssueMessage::Reload(id))))
+
            }
+
            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 => {
+
                self.quit = true;
+
                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(())
+
    }
+

+
    fn create_issue(
+
        &mut self,
+
        title: String,
+
        description: String,
+
        labels: String,
+
        assignees: String,
+
    ) -> Result<IssueId> {
+
        let repository = self.context.repository();
+
        let signer = self.context.signer();
+

+
        let labels = cob::parse_labels(labels)?;
+
        let assignees = cob::parse_assignees(assignees)?;
+

+
        cob::issue::create(
+
            repository,
+
            signer,
+
            title,
+
            description,
+
            labels.as_slice(),
+
            assignees.as_slice(),
+
        )
+
    }
+
}
+

+
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 quit(&self) -> bool {
+
        self.quit
+
    }
+
}
added bin/commands/issue/app/event.rs
@@ -0,0 +1,325 @@
+
use radicle::cob::issue::IssueId;
+
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection, Position};
+
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers};
+
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
+

+
use radicle_tui as tui;
+

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

+
use tui::ui::widget::Widget;
+

+
use super::ui;
+
use super::{IssueCid, IssueMessage, 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,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
+
                match self.perform(Cmd::Move(MoveDirection::Right)) {
+
                    CmdResult::Changed(State::One(StateValue::U16(index))) => {
+
                        Some(Message::NavigationChanged(index))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::LargeList> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => match self.state() {
+
                State::Tup2((StateValue::Usize(selected), StateValue::Usize(_))) => {
+
                    let item = self.items().get(selected)?;
+
                    Some(Message::Issue(IssueMessage::Leave(Some(
+
                        item.id().to_owned(),
+
                    ))))
+
                }
+
                _ => None,
+
            },
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                let result = self.perform(Cmd::Move(MoveDirection::Up));
+
                match result {
+
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                let result = self.perform(Cmd::Move(MoveDirection::Down));
+
                match result {
+
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => Some(Message::Issue(IssueMessage::Focus(IssueCid::Details))),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('o'),
+
                ..
+
            }) => Some(Message::Issue(IssueMessage::OpenForm)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueDetails> {
+
    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::Scroll(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Scroll(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Issue(IssueMessage::Focus(IssueCid::List)))
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Form> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Left, ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Left));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Right, ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Right));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Home, ..
+
            }) => {
+
                self.perform(Cmd::GoTo(Position::Begin));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
+
                self.perform(Cmd::GoTo(Position::End));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Delete, ..
+
            }) => {
+
                self.perform(Cmd::Cancel);
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Backspace,
+
                ..
+
            }) => {
+
                self.perform(Cmd::Delete);
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => {
+
                self.perform(Cmd::Custom(Form::CMD_NEWLINE));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('s'),
+
                modifiers: KeyModifiers::CONTROL,
+
            }) => {
+
                self.perform(Cmd::Submit);
+
                self.query(tuirealm::Attribute::Custom(Form::PROP_ID))
+
                    .map(|cid| Message::FormSubmitted(cid.unwrap_string()))
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Issue(IssueMessage::HideForm))
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::BackTab, ..
+
            }) => {
+
                self.perform(Cmd::Custom(Form::CMD_FOCUS_PREVIOUS));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
+
                self.perform(Cmd::Custom(Form::CMD_FOCUS_NEXT));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char(ch),
+
                modifiers: KeyModifiers::SHIFT,
+
            }) => {
+
                self.perform(Cmd::Type(ch.to_ascii_uppercase()));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('v'),
+
                modifiers: KeyModifiers::CONTROL,
+
            }) => {
+
                self.perform(Cmd::Custom(Form::CMD_PASTE));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char(ch),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Type(ch));
+
                Some(Message::Tick)
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueBrowser> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        let mut submit = || -> Option<IssueId> {
+
            let result = self.perform(Cmd::Submit);
+
            match result {
+
                CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
+
                    let item = self.items().get(selected)?;
+
                    Some(item.id().to_owned())
+
                }
+
                _ => None,
+
            }
+
        };
+

+
        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::Char('o'),
+
                ..
+
            }) => {
+
                let id = submit();
+
                Some(Message::Batch(vec![
+
                    Message::Issue(IssueMessage::Show(id)),
+
                    Message::Issue(IssueMessage::OpenForm),
+
                ]))
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => {
+
                let id = submit();
+
                if id.is_some() {
+
                    Some(Message::Issue(IssueMessage::Show(id)))
+
                } else {
+
                    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<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/issue/app/page.rs
@@ -0,0 +1,553 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use radicle::cob::issue::{Issue, IssueId};
+

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

+
use radicle_tui as tui;
+

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

+
use super::{
+
    Application, Cid, IssueCid, IssueCobMessage, IssueMessage, ListCid, Message, PopupMessage,
+
};
+

+
use super::subscription;
+
use super::ui;
+

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

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

+
    fn activate(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        self.active_component = cid;
+
        let cid = Cid::List(self.active_component.clone());
+
        app.active(&cid)?;
+
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
+

+
        Ok(())
+
    }
+

+
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::IssueBrowser,
+
            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, "o", "open"),
+
                    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::IssueBrowser))?;
+
        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 ListPage {
+
    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 issue_browser = ui::issues(context, theme, None).to_boxed();
+

+
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
+
        app.remount(Cid::List(ListCid::IssueBrowser), issue_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::IssueBrowser))?;
+
        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>> {
+
        if let Message::Issue(IssueMessage::Reload(id)) = message {
+
            let selected = match id {
+
                Some(id) => cob::issue::find(context.repository(), &id)?.map(|issue| (id, issue)),
+
                _ => None,
+
            };
+

+
            let issue_browser = ui::issues(context, theme, selected).to_boxed();
+
            app.remount(Cid::List(ListCid::IssueBrowser), issue_browser, vec![])?;
+

+
            self.activate(app, ListCid::IssueBrowser)?;
+
        }
+

+
        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::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(())
+
    }
+
}
+

+
///
+
/// Issue detail page
+
///
+
pub struct IssuePage {
+
    issue: Option<(IssueId, Issue)>,
+
    active_component: IssueCid,
+
    shortcuts: HashMap<IssueCid, Widget<Shortcuts>>,
+
}
+

+
impl IssuePage {
+
    pub fn new(_context: &Context, theme: &Theme, issue: Option<(IssueId, Issue)>) -> Self {
+
        let shortcuts = Self::build_shortcuts(theme);
+
        let active_component = IssueCid::List;
+

+
        Self {
+
            issue,
+
            active_component,
+
            shortcuts,
+
        }
+
    }
+

+
    fn activate(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: IssueCid,
+
    ) -> Result<()> {
+
        self.active_component = cid;
+
        let cid = Cid::Issue(self.active_component.clone());
+
        app.active(&cid)?;
+
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
+

+
        Ok(())
+
    }
+

+
    fn build_shortcuts(theme: &Theme) -> HashMap<IssueCid, Widget<Shortcuts>> {
+
        [
+
            (
+
                IssueCid::List,
+
                tui::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "↑/↓", "navigate"),
+
                        tui::ui::shortcut(theme, "enter", "show"),
+
                        tui::ui::shortcut(theme, "o", "open"),
+
                        tui::ui::shortcut(theme, "q", "quit"),
+
                    ],
+
                ),
+
            ),
+
            (
+
                IssueCid::Details,
+
                tui::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "↑/↓", "scroll"),
+
                        tui::ui::shortcut(theme, "q", "quit"),
+
                    ],
+
                ),
+
            ),
+
            (
+
                IssueCid::Form,
+
                tui::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "shift + tab / tab", "navigate"),
+
                        tui::ui::shortcut(theme, "ctrl + s", "submit"),
+
                    ],
+
                ),
+
            ),
+
        ]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        cid: IssueCid,
+
    ) -> Result<()> {
+
        let context = match cid {
+
            IssueCid::List => {
+
                let state = app.state(&Cid::Issue(IssueCid::List))?;
+
                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);
+
                Some(context)
+
            }
+
            IssueCid::Details => {
+
                let state = app.state(&Cid::Issue(IssueCid::Details))?;
+
                let progress = match state {
+
                    State::One(StateValue::Usize(scroll)) => Progress::Percentage(scroll),
+
                    _ => Progress::None,
+
                };
+
                let context = ui::description_context(context, theme, progress);
+
                Some(context)
+
            }
+
            IssueCid::Form => {
+
                let context = ui::form_context(context, theme, Progress::None);
+
                Some(context)
+
            }
+
            _ => None,
+
        };
+

+
        if let Some(context) = context {
+
            app.remount(Cid::Issue(IssueCid::Context), context.to_boxed(), vec![])?;
+
        }
+

+
        Ok(())
+
    }
+

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

+
impl ViewPage<Cid, Message> for IssuePage {
+
    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 list = ui::list(context, theme, self.issue.clone()).to_boxed();
+

+
        app.remount(Cid::Issue(IssueCid::Header), header, vec![])?;
+
        app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+

+
        if let Some((id, issue)) = &self.issue {
+
            let comments = issue.comments().collect::<Vec<_>>();
+
            let details = ui::details(
+
                context,
+
                theme,
+
                (*id, issue.clone()),
+
                comments.first().copied(),
+
            )
+
            .to_boxed();
+
            app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
+
        }
+

+
        app.active(&Cid::Issue(self.active_component.clone()))?;
+

+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme, self.active_component.clone())?;
+

+
        Ok(())
+
    }
+

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

+
        if app.mounted(&Cid::Issue(IssueCid::Details)) {
+
            app.umount(&Cid::Issue(IssueCid::Details))?;
+
        }
+

+
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
+
            app.umount(&Cid::Issue(IssueCid::Form))?;
+
        }
+

+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        match message {
+
            Message::Issue(IssueMessage::Created(id)) => {
+
                let repo = context.repository();
+

+
                if let Some(issue) = cob::issue::find(repo, &id)? {
+
                    self.issue = Some((id, issue.clone()));
+
                    let list = ui::list(context, theme, self.issue.clone()).to_boxed();
+
                    let comments = issue.comments().collect::<Vec<_>>();
+

+
                    let details = ui::details(
+
                        context,
+
                        theme,
+
                        (id, issue.clone()),
+
                        comments.first().copied(),
+
                    )
+
                    .to_boxed();
+

+
                    app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
+
                }
+
            }
+
            Message::Issue(IssueMessage::Changed(id)) => {
+
                let repo = context.repository();
+
                if let Some(issue) = cob::issue::find(repo, &id)? {
+
                    self.issue = Some((id, issue.clone()));
+
                    let comments = issue.comments().collect::<Vec<_>>();
+
                    let details = ui::details(
+
                        context,
+
                        theme,
+
                        (id, issue.clone()),
+
                        comments.first().copied(),
+
                    )
+
                    .to_boxed();
+
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
+
                }
+
            }
+
            Message::Issue(IssueMessage::Focus(cid)) => {
+
                self.activate(app, cid)?;
+
                self.update_shortcuts(app, self.active_component.clone())?;
+
            }
+
            Message::Issue(IssueMessage::OpenForm) => {
+
                let new_form = ui::new_form(context, theme).to_boxed();
+
                let list = ui::list(context, theme, None).to_boxed();
+

+
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+
                app.remount(Cid::Issue(IssueCid::Form), new_form, vec![])?;
+
                app.active(&Cid::Issue(IssueCid::Form))?;
+

+
                app.unsubscribe(&Cid::GlobalListener, subscription::global_clause())?;
+

+
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::Form))));
+
            }
+
            Message::Issue(IssueMessage::HideForm) => {
+
                app.umount(&Cid::Issue(IssueCid::Form))?;
+

+
                let list = ui::list(context, theme, self.issue.clone()).to_boxed();
+
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+

+
                app.subscribe(
+
                    &Cid::GlobalListener,
+
                    Sub::new(subscription::global_clause(), SubClause::Always),
+
                )?;
+

+
                if self.issue.is_none() {
+
                    return Ok(Some(Message::Issue(IssueMessage::Leave(None))));
+
                }
+
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::List))));
+
            }
+
            Message::FormSubmitted(id) => {
+
                if id == ui::FORM_ID_EDIT {
+
                    let state = app.state(&Cid::Issue(IssueCid::Form))?;
+
                    if let State::Linked(mut states) = state {
+
                        let mut missing_values = vec![];
+

+
                        let title = match states.front() {
+
                            Some(State::One(StateValue::String(title))) if !title.is_empty() => {
+
                                Some(title.clone())
+
                            }
+
                            _ => None,
+
                        };
+
                        states.pop_front();
+

+
                        let tags = match states.front() {
+
                            Some(State::One(StateValue::String(tags))) => Some(tags.clone()),
+
                            _ => Some(String::from("[]")),
+
                        };
+
                        states.pop_front();
+

+
                        let assignees = match states.front() {
+
                            Some(State::One(StateValue::String(assignees))) => {
+
                                Some(assignees.clone())
+
                            }
+
                            _ => Some(String::from("[]")),
+
                        };
+
                        states.pop_front();
+

+
                        let description = match states.front() {
+
                            Some(State::One(StateValue::String(description)))
+
                                if !description.is_empty() =>
+
                            {
+
                                Some(description.clone())
+
                            }
+
                            _ => None,
+
                        };
+
                        states.pop_front();
+

+
                        if title.is_none() {
+
                            missing_values.push("title");
+
                        }
+
                        if description.is_none() {
+
                            missing_values.push("description");
+
                        }
+

+
                        // show error popup if missing.
+
                        if !missing_values.is_empty() {
+
                            let error = format!("Missing fields: {:?}", missing_values);
+
                            return Ok(Some(Message::Popup(PopupMessage::Error(error))));
+
                        } else {
+
                            return Ok(Some(Message::Issue(IssueMessage::Cob(
+
                                IssueCobMessage::Create {
+
                                    title: title.unwrap(),
+
                                    tags: tags.unwrap(),
+
                                    assignees: assignees.unwrap(),
+
                                    description: description.unwrap(),
+
                                },
+
                            ))));
+
                        }
+
                    }
+
                }
+
            }
+
            _ => {}
+
        }
+

+
        self.update_context(app, context, theme, self.active_component.clone())?;
+

+
        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::issue_page(area, shortcuts_h);
+

+
        app.view(&Cid::Issue(IssueCid::Header), frame, layout.header);
+
        app.view(&Cid::Issue(IssueCid::List), frame, layout.left);
+

+
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
+
            app.view(&Cid::Issue(IssueCid::Form), frame, layout.right);
+
        } else if app.mounted(&Cid::Issue(IssueCid::Details)) {
+
            app.view(&Cid::Issue(IssueCid::Details), frame, layout.right);
+
        }
+

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

+
    fn subscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        Ok(())
+
    }
+

+
    fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        Ok(())
+
    }
+
}
added bin/commands/issue/app/ui.rs
@@ -0,0 +1,436 @@
+
use radicle::cob::thread::Comment;
+
use radicle::cob::thread::CommentId;
+

+
use radicle::cob::issue::Issue;
+
use radicle::cob::issue::IssueId;
+

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

+
use radicle_tui as tui;
+

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

+
use tui::ui::widget::container::{Container, Tabs};
+
use tui::ui::widget::context::{ContextBar, Progress};
+
use tui::ui::widget::form::{Form, TextArea, TextField};
+
use tui::ui::widget::label::Textarea;
+
use tui::ui::widget::list::{ColumnWidth, List, Property, Table};
+

+
pub const FORM_ID_EDIT: &str = "edit-form";
+

+
pub struct IssueBrowser {
+
    items: Vec<IssueItem>,
+
    table: Widget<Table<IssueItem, 7>>,
+
}
+

+
impl IssueBrowser {
+
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
+
        let header = [
+
            tui::ui::label(" ● "),
+
            tui::ui::label("ID"),
+
            tui::ui::label("Title"),
+
            tui::ui::label("Author"),
+
            tui::ui::label("Tags"),
+
            tui::ui::label("Assignees"),
+
            tui::ui::label("Opened"),
+
        ];
+

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

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

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

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

+
        let selected = match selected {
+
            Some((id, issue)) => Some(IssueItem::from((context.profile(), repo, id, issue))),
+
            _ => 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<IssueItem> {
+
        &self.items
+
    }
+
}
+

+
impl WidgetComponent for IssueBrowser {
+
    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 struct LargeList {
+
    items: Vec<IssueItem>,
+
    list: Widget<Container>,
+
}
+

+
impl LargeList {
+
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
+
        let repo = context.repository();
+

+
        let mut items = context
+
            .issues()
+
            .iter()
+
            .map(|(id, issue)| IssueItem::from((context.profile(), repo, *id, issue.clone())))
+
            .collect::<Vec<_>>();
+

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

+
        let selected =
+
            selected.map(|(id, issue)| IssueItem::from((context.profile(), repo, id, issue)));
+

+
        let list = Widget::new(List::new(&items, selected, theme.clone()))
+
            .highlight(theme.colors.item_list_highlighted_bg);
+

+
        let container = tui::ui::container(theme, list.to_boxed());
+

+
        Self {
+
            items,
+
            list: container,
+
        }
+
    }
+

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

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

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

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

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

+
pub struct IssueHeader {
+
    container: Widget<Container>,
+
}
+

+
impl IssueHeader {
+
    pub fn new(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Self {
+
        let repo = context.repository();
+

+
        let (id, issue) = issue;
+
        let by_you = *issue.author().id() == context.profile().did();
+
        let item = IssueItem::from((context.profile(), repo, id, issue.clone()));
+

+
        let title = Property::new(
+
            tui::ui::label("Title").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(item.title()).foreground(theme.colors.browser_list_title),
+
        );
+

+
        let author = Property::new(
+
            tui::ui::label("Author").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(&cob::format_author(issue.author().id(), by_you))
+
                .foreground(theme.colors.browser_list_author),
+
        );
+

+
        let issue_id = Property::new(
+
            tui::ui::label("Issue").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(&id.to_string()).foreground(theme.colors.browser_list_description),
+
        );
+

+
        let labels = Property::new(
+
            tui::ui::label("Labels").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(&cob::format_labels(item.labels()))
+
                .foreground(theme.colors.browser_list_labels),
+
        );
+

+
        let assignees = Property::new(
+
            tui::ui::label("Assignees").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(&cob::format_assignees(
+
                &item
+
                    .assignees()
+
                    .iter()
+
                    .map(|item| (item.did(), item.is_you()))
+
                    .collect::<Vec<_>>(),
+
            ))
+
            .foreground(theme.colors.browser_list_author),
+
        );
+

+
        let state = Property::new(
+
            tui::ui::label("Status").foreground(theme.colors.property_name_fg),
+
            tui::ui::label(&item.state().to_string()).foreground(theme.colors.browser_list_title),
+
        );
+

+
        let table = tui::ui::property_table(
+
            theme,
+
            vec![
+
                Widget::new(title),
+
                Widget::new(issue_id),
+
                Widget::new(author),
+
                Widget::new(labels),
+
                Widget::new(assignees),
+
                Widget::new(state),
+
            ],
+
        );
+
        let container = tui::ui::container(theme, table.to_boxed());
+

+
        Self { container }
+
    }
+
}
+

+
impl WidgetComponent for IssueHeader {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        self.container.view(frame, area);
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct IssueDetails {
+
    header: Widget<IssueHeader>,
+
    description: Widget<CommentBody>,
+
}
+

+
impl IssueDetails {
+
    pub fn new(
+
        context: &Context,
+
        theme: &Theme,
+
        issue: (IssueId, Issue),
+
        description: Option<(&CommentId, &Comment)>,
+
    ) -> Self {
+
        Self {
+
            header: header(context, theme, issue),
+
            description: self::description(context, theme, description),
+
        }
+
    }
+
}
+

+
impl WidgetComponent for IssueDetails {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints([Constraint::Length(8), Constraint::Min(1)])
+
            .split(area);
+

+
        self.header.view(frame, layout[0]);
+

+
        self.description
+
            .attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.description.view(frame, layout[1]);
+
    }
+

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

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

+
pub struct CommentBody {
+
    textarea: Widget<Container>,
+
}
+

+
impl CommentBody {
+
    pub fn new(_context: &Context, theme: &Theme, comment: Option<(&CommentId, &Comment)>) -> Self {
+
        let content = match comment {
+
            Some((_, comment)) => comment.body().to_string(),
+
            None => String::new(),
+
        };
+
        let textarea = Widget::new(Textarea::new(theme.clone()))
+
            .content(AttrValue::String(content))
+
            .foreground(theme.colors.default_fg);
+

+
        let textarea = tui::ui::container(theme, textarea.to_boxed());
+

+
        Self { textarea }
+
    }
+
}
+

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

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

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

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

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

+
pub fn list(
+
    context: &Context,
+
    theme: &Theme,
+
    issue: Option<(IssueId, Issue)>,
+
) -> Widget<LargeList> {
+
    let list = LargeList::new(context, theme, issue);
+

+
    Widget::new(list)
+
}
+

+
pub fn header(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<IssueHeader> {
+
    let header = IssueHeader::new(context, theme, issue);
+
    Widget::new(header)
+
}
+

+
pub fn description(
+
    context: &Context,
+
    theme: &Theme,
+
    comment: Option<(&CommentId, &Comment)>,
+
) -> Widget<CommentBody> {
+
    let body = CommentBody::new(context, theme, comment);
+
    Widget::new(body)
+
}
+

+
pub fn new_form(_context: &Context, theme: &Theme) -> Widget<Form> {
+
    use tuirealm::props::Layout;
+

+
    let title = Widget::new(TextField::new(theme.clone(), "Title")).to_boxed();
+
    let tags = Widget::new(TextField::new(theme.clone(), "Labels (bug, ...)")).to_boxed();
+
    let assignees = Widget::new(TextField::new(
+
        theme.clone(),
+
        "Assignees (z6MkvAdxCp1oLVVTsqYvev9YrhSN3gBQNUSM45hhy4pgkexk, ...)",
+
    ))
+
    .to_boxed();
+
    let description = Widget::new(TextArea::new(theme.clone(), "Description")).to_boxed();
+
    let inputs: Vec<Box<dyn MockComponent>> = vec![title, tags, assignees, description];
+

+
    let layout = Layout::default().constraints(
+
        [
+
            Constraint::Length(3),
+
            Constraint::Length(3),
+
            Constraint::Length(3),
+
            Constraint::Min(3),
+
        ]
+
        .as_ref(),
+
    );
+

+
    Widget::new(Form::new(theme.clone(), inputs))
+
        .custom(Form::PROP_ID, AttrValue::String(String::from(FORM_ID_EDIT)))
+
        .layout(layout)
+
}
+

+
pub fn details(
+
    context: &Context,
+
    theme: &Theme,
+
    issue: (IssueId, Issue),
+
    comment: Option<(&CommentId, &Comment)>,
+
) -> Widget<IssueDetails> {
+
    let discussion = IssueDetails::new(context, theme, issue, comment);
+
    Widget::new(discussion)
+
}
+

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

+
    let issues = context.issues();
+
    let open = issues
+
        .iter()
+
        .filter(|issue| *issue.1.state() == State::Open)
+
        .collect::<Vec<_>>()
+
        .len();
+
    let closed = issues
+
        .iter()
+
        .filter(|issue| *issue.1.state() != State::Open)
+
        .collect::<Vec<_>>()
+
        .len();
+

+
    tui::ui::widget::context::bar(
+
        theme,
+
        "Browse",
+
        "",
+
        "",
+
        &format!("{open} open | {closed} closed"),
+
        &progress.to_string(),
+
    )
+
}
+

+
pub fn description_context(
+
    _context: &Context,
+
    theme: &Theme,
+
    progress: Progress,
+
) -> Widget<ContextBar> {
+
    tui::ui::widget::context::bar(theme, "Show", "", "", "", &progress.to_string())
+
}
+

+
pub fn form_context(_context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
+
    tui::ui::widget::context::bar(theme, "Open", "", "", "", &progress.to_string())
+
        .custom(ContextBar::PROP_EDIT_MODE, AttrValue::Flag(true))
+
}
+

+
pub fn issues(
+
    context: &Context,
+
    theme: &Theme,
+
    selected: Option<(IssueId, Issue)>,
+
) -> Widget<IssueBrowser> {
+
    Widget::new(IssueBrowser::new(context, theme, selected))
+
}
added bin/commands/issue/main.rs
@@ -0,0 +1,87 @@
+
mod app;
+

+
use std::process;
+

+
use anyhow::anyhow;
+

+
use radicle::profile;
+

+
use log::info;
+
use log::LevelFilter;
+

+
use radicle_term as term;
+
use radicle_tui as tui;
+

+
use tui::context;
+
use tui::Window;
+

+
pub const NAME: &str = "rad-issue-tui";
+
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+
pub const GIT_HEAD: &str = env!("GIT_HEAD");
+
pub const FPS: u64 = 60;
+

+
pub const HELP: &str = r#"
+
Usage
+

+
    rad-issue-tui [<option>...]
+

+
Options
+

+
    --version       Print version
+
    --help          Print help
+

+
"#;
+

+
struct Options;
+

+
impl Options {
+
    #[allow(clippy::never_loop)]
+
    fn from_env() -> Result<Self, anyhow::Error> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_env();
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("version") => {
+
                    println!("{NAME} {VERSION}+{GIT_HEAD}");
+
                    process::exit(0);
+
                }
+
                Long("help") | Short('h') => {
+
                    println!("{HELP}");
+
                    process::exit(0);
+
                }
+
                _ => anyhow::bail!(arg.unexpected()),
+
            }
+
        }
+

+
        Ok(Self {})
+
    }
+
}
+

+
fn execute() -> anyhow::Result<()> {
+
    let _ = Options::from_env()?;
+

+
    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)?;
+

+
    let logfile = format!(
+
        "{}/rad-issue-tui.log",
+
        profile::home()?.path().to_string_lossy()
+
    );
+
    simple_logging::log_to_file(logfile, LevelFilter::Info)?;
+
    info!("Launching window...");
+

+
    let mut window = Window::default();
+
    window.run(&mut app::App::new(context), 1000 / FPS)?;
+

+
    Ok(())
+
}
+

+
fn main() {
+
    if let Err(err) = execute() {
+
        term::error(format!("Error: rad-issue-tui: {err}"));
+
        process::exit(1);
+
    }
+
}
added bin/commands/patch.rs
@@ -0,0 +1,397 @@
+
pub mod event;
+
pub mod page;
+
pub mod subscription;
+

+
use anyhow::Result;
+

+
use radicle::cob::issue::IssueId;
+
use radicle::cob::patch::PatchId;
+
use radicle::identity::{Id, Project};
+
use radicle::prelude::Signer;
+
use radicle::profile::Profile;
+

+
use radicle_tui::ui::widget;
+
use tuirealm::application::PollStrategy;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

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

+
use page::{HomeView, PatchView};
+

+
use self::page::{IssuePage, PageStack};
+

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

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

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum IssueCid {
+
    Header,
+
    List,
+
    Details,
+
    Context,
+
    Form,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    Home(HomeCid),
+
    Issue(IssueCid),
+
    Patch(PatchCid),
+
    GlobalListener,
+
    Popup,
+
}
+

+
/// Messages handled by this application.
+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum HomeMessage {
+
    RefreshIssues(Option<IssueId>),
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum IssueCobMessage {
+
    Create {
+
        title: String,
+
        tags: String,
+
        assignees: String,
+
        description: String,
+
    },
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum IssueMessage {
+
    Show(Option<IssueId>),
+
    Changed(IssueId),
+
    Focus(IssueCid),
+
    Created(IssueId),
+
    Cob(IssueCobMessage),
+
    OpenForm,
+
    HideForm,
+
    Leave(Option<IssueId>),
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum PatchMessage {
+
    Show(PatchId),
+
    Leave,
+
}
+

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

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    Home(HomeMessage),
+
    Issue(IssueMessage),
+
    Patch(PatchMessage),
+
    NavigationChanged(u16),
+
    FormSubmitted(String),
+
    Popup(PopupMessage),
+
    Tick,
+
    Quit,
+
    Batch(Vec<Message>),
+
}
+

+
#[allow(dead_code)]
+
pub struct App {
+
    context: Context,
+
    pages: PageStack,
+
    theme: Theme,
+
    quit: bool,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
impl App {
+
    pub fn new(profile: Profile, id: Id, project: Project, signer: Box<dyn Signer>) -> Self {
+
        Self {
+
            context: Context::new(profile, id, project, signer),
+
            pages: PageStack::default(),
+
            theme: theme::default_dark(),
+
            quit: false,
+
        }
+
    }
+

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

+
        Ok(())
+
    }
+

+
    fn view_patch(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        id: PatchId,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let repo = self.context.repository();
+

+
        if let Some(patch) = cob::patch::find(repo, &id)? {
+
            let view = Box::new(PatchView::new(theme.clone(), (id, patch)));
+
            self.pages.push(view, app, &self.context, theme)?;
+

+
            Ok(())
+
        } else {
+
            Err(anyhow::anyhow!(
+
                "Could not mount 'page::PatchView'. Patch not found."
+
            ))
+
        }
+
    }
+

+
    fn view_issue(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        id: Option<IssueId>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let repo = self.context.repository();
+
        match id {
+
            Some(id) => {
+
                if let Some(issue) = cob::issue::find(repo, &id)? {
+
                    let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue))));
+
                    self.pages.push(view, app, &self.context, theme)?;
+

+
                    Ok(())
+
                } else {
+
                    Err(anyhow::anyhow!(
+
                        "Could not mount 'page::IssueView'. Issue not found."
+
                    ))
+
                }
+
            }
+
            None => {
+
                let view = Box::new(IssuePage::new(&self.context, theme, None));
+
                self.pages.push(view, 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::Issue(IssueMessage::Cob(IssueCobMessage::Create {
+
                title,
+
                tags,
+
                assignees,
+
                description,
+
            })) => match self.create_issue(title, description, tags, assignees) {
+
                Ok(id) => {
+
                    self.context.reload();
+

+
                    Ok(Some(Message::Batch(vec![
+
                        Message::Issue(IssueMessage::HideForm),
+
                        Message::Issue(IssueMessage::Created(id)),
+
                    ])))
+
                }
+
                Err(err) => {
+
                    let error = format!("{:?}", err);
+
                    self.show_error_popup(app, &theme, &error)?;
+

+
                    Ok(None)
+
                }
+
            },
+
            Message::Issue(IssueMessage::Show(id)) => {
+
                self.view_issue(app, id, &theme)?;
+
                Ok(None)
+
            }
+
            Message::Issue(IssueMessage::Leave(id)) => {
+
                self.pages.pop(app)?;
+
                Ok(Some(Message::Home(HomeMessage::RefreshIssues(id))))
+
            }
+
            Message::Patch(PatchMessage::Show(id)) => {
+
                self.view_patch(app, id, &theme)?;
+
                Ok(None)
+
            }
+
            Message::Patch(PatchMessage::Leave) => {
+
                self.pages.pop(app)?;
+
                Ok(None)
+
            }
+
            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 => {
+
                self.quit = true;
+
                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 = widget::common::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 = widget::common::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 = widget::common::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(())
+
    }
+

+
    fn create_issue(
+
        &mut self,
+
        title: String,
+
        description: String,
+
        labels: String,
+
        assignees: String,
+
    ) -> Result<IssueId> {
+
        let repository = self.context.repository();
+
        let signer = self.context.signer();
+

+
        let labels = cob::parse_labels(labels)?;
+
        let assignees = cob::parse_assignees(assignees)?;
+

+
        cob::issue::create(
+
            repository,
+
            signer,
+
            title,
+
            description,
+
            labels.as_slice(),
+
            assignees.as_slice(),
+
        )
+
    }
+
}
+

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

+
        // Add global key listener and subscribe to key events
+
        let global = ui::widget::common::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 quit(&self) -> bool {
+
        self.quit
+
    }
+
}
added bin/commands/patch/app.rs
@@ -0,0 +1,274 @@
+
mod event;
+
mod page;
+
mod ui;
+

+
use std::hash::Hash;
+

+
use anyhow::Result;
+

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

+
use tuirealm::application::PollStrategy;
+
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 page::{ListView, PatchView};
+

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

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

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

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub enum PatchMessage {
+
    Show(PatchId),
+
    Leave,
+
}
+

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

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    Patch(PatchMessage),
+
    NavigationChanged(u16),
+
    FormSubmitted(String),
+
    Popup(PopupMessage),
+
    #[default]
+
    Tick,
+
    Quit,
+
    Batch(Vec<Message>),
+
}
+

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

+
/// 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,
+
        }
+
    }
+

+
    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 view_patch(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        id: PatchId,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let repo = self.context.repository();
+

+
        if let Some(patch) = cob::patch::find(repo, &id)? {
+
            let view = Box::new(PatchView::new(theme.clone(), (id, patch)));
+
            self.pages.push(view, app, &self.context, theme)?;
+

+
            Ok(())
+
        } else {
+
            Err(anyhow::anyhow!(
+
                "Could not mount 'page::PatchView'. Patch not found."
+
            ))
+
        }
+
    }
+

+
    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::Patch(PatchMessage::Show(id)) => {
+
                self.view_patch(app, id, &theme)?;
+
                Ok(None)
+
            }
+
            Message::Patch(PatchMessage::Leave) => {
+
                self.pages.pop(app)?;
+
                Ok(None)
+
            }
+
            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 => {
+
                self.quit = true;
+
                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 quit(&self) -> bool {
+
        self.quit
+
    }
+
}
added bin/commands/patch/app/event.rs
@@ -0,0 +1,139 @@
+
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, PatchMessage, 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,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
+
                match self.perform(Cmd::Move(MoveDirection::Right)) {
+
                    CmdResult::Changed(State::One(StateValue::U16(index))) => {
+
                        Some(Message::NavigationChanged(index))
+
                    }
+
                    _ => 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::Patch(PatchMessage::Show(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

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

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::Files> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Patch(PatchMessage::Leave))
+
            }
+
            _ => 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<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/app/page.rs
@@ -0,0 +1,326 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use radicle::cob::patch::{Patch, PatchId};
+

+
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, PatchCid};
+

+
///
+
/// 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(())
+
    }
+
}
+

+
///
+
/// Patch detail page
+
///
+
pub struct PatchView {
+
    active_component: PatchCid,
+
    patch: (PatchId, Patch),
+
    shortcuts: HashMap<PatchCid, Widget<Shortcuts>>,
+
}
+

+
impl PatchView {
+
    pub fn new(theme: Theme, patch: (PatchId, Patch)) -> Self {
+
        let shortcuts = Self::build_shortcuts(&theme);
+
        PatchView {
+
            active_component: PatchCid::Activity,
+
            patch,
+
            shortcuts,
+
        }
+
    }
+

+
    fn build_shortcuts(theme: &Theme) -> HashMap<PatchCid, Widget<Shortcuts>> {
+
        [
+
            (
+
                PatchCid::Activity,
+
                tui::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "tab", "section"),
+
                        tui::ui::shortcut(theme, "q", "quit"),
+
                    ],
+
                ),
+
            ),
+
            (
+
                PatchCid::Files,
+
                tui::ui::shortcuts(
+
                    theme,
+
                    vec![
+
                        tui::ui::shortcut(theme, "esc", "back"),
+
                        tui::ui::shortcut(theme, "tab", "section"),
+
                        tui::ui::shortcut(theme, "q", "quit"),
+
                    ],
+
                ),
+
            ),
+
        ]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+

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

+
impl ViewPage<Cid, Message> for PatchView {
+
    fn mount(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = ui::navigation(theme);
+
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
+
        let activity = ui::activity(theme).to_boxed();
+
        let files = ui::files(theme).to_boxed();
+
        let context = ui::context(context, theme, self.patch.clone()).to_boxed();
+

+
        app.remount(Cid::Patch(PatchCid::Header), header, vec![])?;
+
        app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?;
+
        app.remount(Cid::Patch(PatchCid::Files), files, vec![])?;
+
        app.remount(Cid::Patch(PatchCid::Context), context, vec![])?;
+

+
        let active_component = Cid::Patch(self.active_component.clone());
+
        app.active(&active_component)?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+

+
        Ok(())
+
    }
+

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

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        _context: &Context,
+
        _theme: &Theme,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        if let Message::NavigationChanged(index) = message {
+
            self.active_component = PatchCid::from(index as usize);
+

+
            let active_component = Cid::Patch(self.active_component.clone());
+
            app.active(&active_component)?;
+
            self.update_shortcuts(app, self.active_component.clone())?;
+
        }
+

+
        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::Patch(PatchCid::Header), frame, layout.navigation);
+
        app.view(
+
            &Cid::Patch(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+
        app.view(&Cid::Patch(PatchCid::Context), frame, layout.context);
+
        app.view(&Cid::Patch(PatchCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

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

+
        Ok(())
+
    }
+

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

+
        Ok(())
+
    }
+
}
+

+
impl From<usize> for PatchCid {
+
    fn from(index: usize) -> Self {
+
        match index {
+
            0 => PatchCid::Activity,
+
            1 => PatchCid::Files,
+
            _ => PatchCid::Activity,
+
        }
+
    }
+
}
added bin/commands/patch/app/ui.rs
@@ -0,0 +1,242 @@
+
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;
+
use tui::ui::cob::PatchItem;
+
use tui::ui::layout;
+
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::label::Label;
+
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 struct Activity {
+
    label: Widget<Label>,
+
}
+

+
impl Activity {
+
    pub fn new(label: Widget<Label>) -> Self {
+
        Self { label }
+
    }
+
}
+

+
impl WidgetComponent for Activity {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let label_w = self
+
            .label
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, area));
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct Files {
+
    label: Widget<Label>,
+
}
+

+
impl Files {
+
    pub fn new(label: Widget<Label>) -> Self {
+
        Self { label }
+
    }
+
}
+

+
impl WidgetComponent for Files {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let label_w = self
+
            .label
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, area));
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
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 navigation(theme: &Theme) -> Widget<Tabs> {
+
    tui::ui::tabs(
+
        theme,
+
        vec![
+
            tui::ui::reversable_label("Activity").foreground(theme.colors.tabs_highlighted_fg),
+
            tui::ui::reversable_label("Files").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 activity(theme: &Theme) -> Widget<Activity> {
+
    let not_implemented = tui::ui::label("not implemented").foreground(theme.colors.default_fg);
+
    let activity = Activity::new(not_implemented);
+

+
    Widget::new(activity)
+
}
+

+
pub fn files(theme: &Theme) -> Widget<Files> {
+
    let not_implemented = tui::ui::label("not implemented").foreground(theme.colors.default_fg);
+
    let files = Files::new(not_implemented);
+

+
    Widget::new(files)
+
}
+

+
pub fn context(context: &Context, theme: &Theme, patch: (PatchId, Patch)) -> Widget<ContextBar> {
+
    let (id, patch) = patch;
+
    let (_, rev) = patch.latest();
+
    let is_you = *patch.author().id() == context.profile().did();
+

+
    let id = cob::format::cob(&id);
+
    let title = patch.title();
+
    let author = cob::format_author(patch.author().id(), is_you);
+
    let comments = rev.discussion().len();
+

+
    tui::ui::widget::context::bar(theme, "Patch", &id, title, &author, &comments.to_string())
+
}
+

+
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(),
+
    )
+
}
added bin/commands/patch/main.rs
@@ -0,0 +1,87 @@
+
mod app;
+

+
use std::process;
+

+
use anyhow::anyhow;
+

+
use radicle::profile;
+

+
use log::info;
+
use log::LevelFilter;
+

+
use radicle_term as term;
+
use radicle_tui as tui;
+

+
use tui::context;
+
use tui::Window;
+

+
pub const NAME: &str = "rad-patch-tui";
+
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+
pub const GIT_HEAD: &str = env!("GIT_HEAD");
+
pub const FPS: u64 = 60;
+

+
pub const HELP: &str = r#"
+
Usage
+

+
    rad-patch-tui [<option>...]
+

+
Options
+

+
    --version       Print version
+
    --help          Print help
+

+
"#;
+

+
struct Options;
+

+
impl Options {
+
    #[allow(clippy::never_loop)]
+
    fn from_env() -> Result<Self, anyhow::Error> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_env();
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("version") => {
+
                    println!("{NAME} {VERSION}+{GIT_HEAD}");
+
                    process::exit(0);
+
                }
+
                Long("help") | Short('h') => {
+
                    println!("{HELP}");
+
                    process::exit(0);
+
                }
+
                _ => anyhow::bail!(arg.unexpected()),
+
            }
+
        }
+

+
        Ok(Self {})
+
    }
+
}
+

+
fn execute() -> anyhow::Result<()> {
+
    let _ = Options::from_env()?;
+

+
    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)?;
+

+
    let logfile = format!(
+
        "{}/rad-patch-tui.log",
+
        profile::home()?.path().to_string_lossy()
+
    );
+
    simple_logging::log_to_file(logfile, LevelFilter::Info)?;
+
    info!("Launching window...");
+

+
    let mut window = Window::default();
+
    window.run(&mut app::App::new(context), 1000 / FPS)?;
+

+
    Ok(())
+
}
+

+
fn main() {
+
    if let Err(err) = execute() {
+
        term::error(format!("Error: rad-patch-tui: {err}"));
+
        process::exit(1);
+
    }
+
}
added bin/main.rs
@@ -0,0 +1,3 @@
+
fn main() {
+
    
+
}

\ No newline at end of file
deleted src/issue.rs
@@ -1,397 +0,0 @@
-
pub mod event;
-
pub mod page;
-
pub mod subscription;
-

-
use anyhow::Result;
-

-
use radicle::cob::issue::IssueId;
-
use radicle::cob::patch::PatchId;
-
use radicle::identity::{Id, Project};
-
use radicle::prelude::Signer;
-
use radicle::profile::Profile;
-

-
use radicle_tui::ui::widget;
-
use tuirealm::application::PollStrategy;
-
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui::ui::context::Context;
-
use radicle_tui::ui::theme::{self, Theme};
-
use radicle_tui::Tui;
-
use radicle_tui::{cob, ui};
-

-
use page::{HomeView, PatchView};
-

-
use self::page::{IssuePage, PageStack};
-

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

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum PatchCid {
-
    Header,
-
    Activity,
-
    Files,
-
    Context,
-
    Shortcuts,
-
}
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum IssueCid {
-
    Header,
-
    List,
-
    Details,
-
    Context,
-
    Form,
-
    Shortcuts,
-
}
-

-
/// All component ids known to this application.
-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum Cid {
-
    Home(HomeCid),
-
    Issue(IssueCid),
-
    Patch(PatchCid),
-
    GlobalListener,
-
    Popup,
-
}
-

-
/// Messages handled by this application.
-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum HomeMessage {
-
    RefreshIssues(Option<IssueId>),
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueCobMessage {
-
    Create {
-
        title: String,
-
        tags: String,
-
        assignees: String,
-
        description: String,
-
    },
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueMessage {
-
    Show(Option<IssueId>),
-
    Changed(IssueId),
-
    Focus(IssueCid),
-
    Created(IssueId),
-
    Cob(IssueCobMessage),
-
    OpenForm,
-
    HideForm,
-
    Leave(Option<IssueId>),
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum PatchMessage {
-
    Show(PatchId),
-
    Leave,
-
}
-

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

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum Message {
-
    Home(HomeMessage),
-
    Issue(IssueMessage),
-
    Patch(PatchMessage),
-
    NavigationChanged(u16),
-
    FormSubmitted(String),
-
    Popup(PopupMessage),
-
    Tick,
-
    Quit,
-
    Batch(Vec<Message>),
-
}
-

-
#[allow(dead_code)]
-
pub struct App {
-
    context: Context,
-
    pages: PageStack,
-
    theme: Theme,
-
    quit: bool,
-
}
-

-
/// Creates a new application using a tui-realm-application, mounts all
-
/// components and sets focus to a default one.
-
impl App {
-
    pub fn new(profile: Profile, id: Id, project: Project, signer: Box<dyn Signer>) -> Self {
-
        Self {
-
            context: Context::new(profile, id, project, signer),
-
            pages: PageStack::default(),
-
            theme: theme::default_dark(),
-
            quit: false,
-
        }
-
    }
-

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

-
        Ok(())
-
    }
-

-
    fn view_patch(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: PatchId,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-

-
        if let Some(patch) = cob::patch::find(repo, &id)? {
-
            let view = Box::new(PatchView::new(theme.clone(), (id, patch)));
-
            self.pages.push(view, app, &self.context, theme)?;
-

-
            Ok(())
-
        } else {
-
            Err(anyhow::anyhow!(
-
                "Could not mount 'page::PatchView'. Patch not found."
-
            ))
-
        }
-
    }
-

-
    fn view_issue(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: Option<IssueId>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-
        match id {
-
            Some(id) => {
-
                if let Some(issue) = cob::issue::find(repo, &id)? {
-
                    let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue))));
-
                    self.pages.push(view, app, &self.context, theme)?;
-

-
                    Ok(())
-
                } else {
-
                    Err(anyhow::anyhow!(
-
                        "Could not mount 'page::IssueView'. Issue not found."
-
                    ))
-
                }
-
            }
-
            None => {
-
                let view = Box::new(IssuePage::new(&self.context, theme, None));
-
                self.pages.push(view, 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::Issue(IssueMessage::Cob(IssueCobMessage::Create {
-
                title,
-
                tags,
-
                assignees,
-
                description,
-
            })) => match self.create_issue(title, description, tags, assignees) {
-
                Ok(id) => {
-
                    self.context.reload();
-

-
                    Ok(Some(Message::Batch(vec![
-
                        Message::Issue(IssueMessage::HideForm),
-
                        Message::Issue(IssueMessage::Created(id)),
-
                    ])))
-
                }
-
                Err(err) => {
-
                    let error = format!("{:?}", err);
-
                    self.show_error_popup(app, &theme, &error)?;
-

-
                    Ok(None)
-
                }
-
            },
-
            Message::Issue(IssueMessage::Show(id)) => {
-
                self.view_issue(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Issue(IssueMessage::Leave(id)) => {
-
                self.pages.pop(app)?;
-
                Ok(Some(Message::Home(HomeMessage::RefreshIssues(id))))
-
            }
-
            Message::Patch(PatchMessage::Show(id)) => {
-
                self.view_patch(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Patch(PatchMessage::Leave) => {
-
                self.pages.pop(app)?;
-
                Ok(None)
-
            }
-
            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 => {
-
                self.quit = true;
-
                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 = widget::common::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 = widget::common::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 = widget::common::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(())
-
    }
-

-
    fn create_issue(
-
        &mut self,
-
        title: String,
-
        description: String,
-
        labels: String,
-
        assignees: String,
-
    ) -> Result<IssueId> {
-
        let repository = self.context.repository();
-
        let signer = self.context.signer();
-

-
        let labels = cob::parse_labels(labels)?;
-
        let assignees = cob::parse_assignees(assignees)?;
-

-
        cob::issue::create(
-
            repository,
-
            signer,
-
            title,
-
            description,
-
            labels.as_slice(),
-
            assignees.as_slice(),
-
        )
-
    }
-
}
-

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

-
        // Add global key listener and subscribe to key events
-
        let global = ui::widget::common::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 quit(&self) -> bool {
-
        self.quit
-
    }
-
}
deleted src/issue/app.rs
@@ -1,343 +0,0 @@
-
mod event;
-
mod page;
-
mod ui;
-

-
use anyhow::Result;
-

-
use radicle::cob::issue::IssueId;
-

-
use tuirealm::application::PollStrategy;
-
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

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

-
use page::{IssuePage, ListPage};
-

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

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum IssueCid {
-
    Header,
-
    List,
-
    Details,
-
    Context,
-
    Form,
-
    Shortcuts,
-
}
-

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

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueCobMessage {
-
    Create {
-
        title: String,
-
        tags: String,
-
        assignees: String,
-
        description: String,
-
    },
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueMessage {
-
    Show(Option<IssueId>),
-
    Changed(IssueId),
-
    Focus(IssueCid),
-
    Created(IssueId),
-
    Cob(IssueCobMessage),
-
    Reload(Option<IssueId>),
-
    OpenForm,
-
    HideForm,
-
    Leave(Option<IssueId>),
-
}
-

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

-
#[derive(Clone, Debug, Default, Eq, PartialEq)]
-
pub enum Message {
-
    Issue(IssueMessage),
-
    NavigationChanged(u16),
-
    FormSubmitted(String),
-
    Popup(PopupMessage),
-
    #[default]
-
    Tick,
-
    Quit,
-
    Batch(Vec<Message>),
-
}
-

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

-
/// 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,
-
        }
-
    }
-

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

-
        Ok(())
-
    }
-

-
    fn view_issue(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: Option<IssueId>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-
        match id {
-
            Some(id) => {
-
                if let Some(issue) = cob::issue::find(repo, &id)? {
-
                    let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue))));
-
                    self.pages.push(view, app, &self.context, theme)?;
-

-
                    Ok(())
-
                } else {
-
                    Err(anyhow::anyhow!(
-
                        "Could not mount 'page::IssueView'. Issue not found."
-
                    ))
-
                }
-
            }
-
            None => {
-
                let view = Box::new(IssuePage::new(&self.context, theme, None));
-
                self.pages.push(view, 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::Issue(IssueMessage::Cob(IssueCobMessage::Create {
-
                title,
-
                tags,
-
                assignees,
-
                description,
-
            })) => match self.create_issue(title, description, tags, assignees) {
-
                Ok(id) => {
-
                    self.context.reload();
-

-
                    Ok(Some(Message::Batch(vec![
-
                        Message::Issue(IssueMessage::HideForm),
-
                        Message::Issue(IssueMessage::Created(id)),
-
                    ])))
-
                }
-
                Err(err) => {
-
                    let error = format!("{:?}", err);
-
                    self.show_error_popup(app, &theme, &error)?;
-

-
                    Ok(None)
-
                }
-
            },
-
            Message::Issue(IssueMessage::Show(id)) => {
-
                self.view_issue(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Issue(IssueMessage::Leave(id)) => {
-
                self.pages.pop(app)?;
-
                Ok(Some(Message::Issue(IssueMessage::Reload(id))))
-
            }
-
            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 => {
-
                self.quit = true;
-
                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(())
-
    }
-

-
    fn create_issue(
-
        &mut self,
-
        title: String,
-
        description: String,
-
        labels: String,
-
        assignees: String,
-
    ) -> Result<IssueId> {
-
        let repository = self.context.repository();
-
        let signer = self.context.signer();
-

-
        let labels = cob::parse_labels(labels)?;
-
        let assignees = cob::parse_assignees(assignees)?;
-

-
        cob::issue::create(
-
            repository,
-
            signer,
-
            title,
-
            description,
-
            labels.as_slice(),
-
            assignees.as_slice(),
-
        )
-
    }
-
}
-

-
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 quit(&self) -> bool {
-
        self.quit
-
    }
-
}
deleted src/issue/app/event.rs
@@ -1,325 +0,0 @@
-
use radicle::cob::issue::IssueId;
-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection, Position};
-
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers};
-
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
-

-
use radicle_tui as tui;
-

-
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer, Popup};
-
use tui::ui::widget::context::{ContextBar, Shortcuts};
-
use tui::ui::widget::form::Form;
-
use tui::ui::widget::list::PropertyList;
-

-
use tui::ui::widget::Widget;
-

-
use super::ui;
-
use super::{IssueCid, IssueMessage, 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,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
-
                match self.perform(Cmd::Move(MoveDirection::Right)) {
-
                    CmdResult::Changed(State::One(StateValue::U16(index))) => {
-
                        Some(Message::NavigationChanged(index))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::LargeList> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => match self.state() {
-
                State::Tup2((StateValue::Usize(selected), StateValue::Usize(_))) => {
-
                    let item = self.items().get(selected)?;
-
                    Some(Message::Issue(IssueMessage::Leave(Some(
-
                        item.id().to_owned(),
-
                    ))))
-
                }
-
                _ => None,
-
            },
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => {
-
                let result = self.perform(Cmd::Move(MoveDirection::Up));
-
                match result {
-
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
-
                        let item = self.items().get(selected)?;
-
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                let result = self.perform(Cmd::Move(MoveDirection::Down));
-
                match result {
-
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
-
                        let item = self.items().get(selected)?;
-
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => Some(Message::Issue(IssueMessage::Focus(IssueCid::Details))),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('o'),
-
                ..
-
            }) => Some(Message::Issue(IssueMessage::OpenForm)),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueDetails> {
-
    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::Scroll(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Scroll(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Issue(IssueMessage::Focus(IssueCid::List)))
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<Form> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Left, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Left));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Right, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Right));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Home, ..
-
            }) => {
-
                self.perform(Cmd::GoTo(Position::Begin));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
-
                self.perform(Cmd::GoTo(Position::End));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Delete, ..
-
            }) => {
-
                self.perform(Cmd::Cancel);
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Backspace,
-
                ..
-
            }) => {
-
                self.perform(Cmd::Delete);
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => {
-
                self.perform(Cmd::Custom(Form::CMD_NEWLINE));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('s'),
-
                modifiers: KeyModifiers::CONTROL,
-
            }) => {
-
                self.perform(Cmd::Submit);
-
                self.query(tuirealm::Attribute::Custom(Form::PROP_ID))
-
                    .map(|cid| Message::FormSubmitted(cid.unwrap_string()))
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Issue(IssueMessage::HideForm))
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::BackTab, ..
-
            }) => {
-
                self.perform(Cmd::Custom(Form::CMD_FOCUS_PREVIOUS));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
-
                self.perform(Cmd::Custom(Form::CMD_FOCUS_NEXT));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char(ch),
-
                modifiers: KeyModifiers::SHIFT,
-
            }) => {
-
                self.perform(Cmd::Type(ch.to_ascii_uppercase()));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('v'),
-
                modifiers: KeyModifiers::CONTROL,
-
            }) => {
-
                self.perform(Cmd::Custom(Form::CMD_PASTE));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char(ch),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Type(ch));
-
                Some(Message::Tick)
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueBrowser> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<IssueId> {
-
            let result = self.perform(Cmd::Submit);
-
            match result {
-
                CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        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::Char('o'),
-
                ..
-
            }) => {
-
                let id = submit();
-
                Some(Message::Batch(vec![
-
                    Message::Issue(IssueMessage::Show(id)),
-
                    Message::Issue(IssueMessage::OpenForm),
-
                ]))
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => {
-
                let id = submit();
-
                if id.is_some() {
-
                    Some(Message::Issue(IssueMessage::Show(id)))
-
                } else {
-
                    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<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
-
    }
-
}
deleted src/issue/app/page.rs
@@ -1,553 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

-
use radicle::cob::issue::{Issue, IssueId};
-

-
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, State, StateValue, Sub, SubClause};
-

-
use radicle_tui as tui;
-

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

-
use super::{
-
    Application, Cid, IssueCid, IssueCobMessage, IssueMessage, ListCid, Message, PopupMessage,
-
};
-

-
use super::subscription;
-
use super::ui;
-

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

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

-
    fn activate(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: ListCid,
-
    ) -> Result<()> {
-
        self.active_component = cid;
-
        let cid = Cid::List(self.active_component.clone());
-
        app.active(&cid)?;
-
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
-

-
        Ok(())
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::IssueBrowser,
-
            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, "o", "open"),
-
                    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::IssueBrowser))?;
-
        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 ListPage {
-
    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 issue_browser = ui::issues(context, theme, None).to_boxed();
-

-
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
-
        app.remount(Cid::List(ListCid::IssueBrowser), issue_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::IssueBrowser))?;
-
        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>> {
-
        if let Message::Issue(IssueMessage::Reload(id)) = message {
-
            let selected = match id {
-
                Some(id) => cob::issue::find(context.repository(), &id)?.map(|issue| (id, issue)),
-
                _ => None,
-
            };
-

-
            let issue_browser = ui::issues(context, theme, selected).to_boxed();
-
            app.remount(Cid::List(ListCid::IssueBrowser), issue_browser, vec![])?;
-

-
            self.activate(app, ListCid::IssueBrowser)?;
-
        }
-

-
        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::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(())
-
    }
-
}
-

-
///
-
/// Issue detail page
-
///
-
pub struct IssuePage {
-
    issue: Option<(IssueId, Issue)>,
-
    active_component: IssueCid,
-
    shortcuts: HashMap<IssueCid, Widget<Shortcuts>>,
-
}
-

-
impl IssuePage {
-
    pub fn new(_context: &Context, theme: &Theme, issue: Option<(IssueId, Issue)>) -> Self {
-
        let shortcuts = Self::build_shortcuts(theme);
-
        let active_component = IssueCid::List;
-

-
        Self {
-
            issue,
-
            active_component,
-
            shortcuts,
-
        }
-
    }
-

-
    fn activate(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: IssueCid,
-
    ) -> Result<()> {
-
        self.active_component = cid;
-
        let cid = Cid::Issue(self.active_component.clone());
-
        app.active(&cid)?;
-
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
-

-
        Ok(())
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<IssueCid, Widget<Shortcuts>> {
-
        [
-
            (
-
                IssueCid::List,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "↑/↓", "navigate"),
-
                        tui::ui::shortcut(theme, "enter", "show"),
-
                        tui::ui::shortcut(theme, "o", "open"),
-
                        tui::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                IssueCid::Details,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "↑/↓", "scroll"),
-
                        tui::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                IssueCid::Form,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "shift + tab / tab", "navigate"),
-
                        tui::ui::shortcut(theme, "ctrl + s", "submit"),
-
                    ],
-
                ),
-
            ),
-
        ]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-

-
    fn update_context(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        cid: IssueCid,
-
    ) -> Result<()> {
-
        let context = match cid {
-
            IssueCid::List => {
-
                let state = app.state(&Cid::Issue(IssueCid::List))?;
-
                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);
-
                Some(context)
-
            }
-
            IssueCid::Details => {
-
                let state = app.state(&Cid::Issue(IssueCid::Details))?;
-
                let progress = match state {
-
                    State::One(StateValue::Usize(scroll)) => Progress::Percentage(scroll),
-
                    _ => Progress::None,
-
                };
-
                let context = ui::description_context(context, theme, progress);
-
                Some(context)
-
            }
-
            IssueCid::Form => {
-
                let context = ui::form_context(context, theme, Progress::None);
-
                Some(context)
-
            }
-
            _ => None,
-
        };
-

-
        if let Some(context) = context {
-
            app.remount(Cid::Issue(IssueCid::Context), context.to_boxed(), vec![])?;
-
        }
-

-
        Ok(())
-
    }
-

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

-
impl ViewPage<Cid, Message> for IssuePage {
-
    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 list = ui::list(context, theme, self.issue.clone()).to_boxed();
-

-
        app.remount(Cid::Issue(IssueCid::Header), header, vec![])?;
-
        app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-

-
        if let Some((id, issue)) = &self.issue {
-
            let comments = issue.comments().collect::<Vec<_>>();
-
            let details = ui::details(
-
                context,
-
                theme,
-
                (*id, issue.clone()),
-
                comments.first().copied(),
-
            )
-
            .to_boxed();
-
            app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
        }
-

-
        app.active(&Cid::Issue(self.active_component.clone()))?;
-

-
        self.update_shortcuts(app, self.active_component.clone())?;
-
        self.update_context(app, context, theme, self.active_component.clone())?;
-

-
        Ok(())
-
    }
-

-
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.umount(&Cid::Issue(IssueCid::Header))?;
-
        app.umount(&Cid::Issue(IssueCid::List))?;
-
        app.umount(&Cid::Issue(IssueCid::Context))?;
-
        app.umount(&Cid::Issue(IssueCid::Shortcuts))?;
-

-
        if app.mounted(&Cid::Issue(IssueCid::Details)) {
-
            app.umount(&Cid::Issue(IssueCid::Details))?;
-
        }
-

-
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
-
            app.umount(&Cid::Issue(IssueCid::Form))?;
-
        }
-

-
        Ok(())
-
    }
-

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        match message {
-
            Message::Issue(IssueMessage::Created(id)) => {
-
                let repo = context.repository();
-

-
                if let Some(issue) = cob::issue::find(repo, &id)? {
-
                    self.issue = Some((id, issue.clone()));
-
                    let list = ui::list(context, theme, self.issue.clone()).to_boxed();
-
                    let comments = issue.comments().collect::<Vec<_>>();
-

-
                    let details = ui::details(
-
                        context,
-
                        theme,
-
                        (id, issue.clone()),
-
                        comments.first().copied(),
-
                    )
-
                    .to_boxed();
-

-
                    app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
                }
-
            }
-
            Message::Issue(IssueMessage::Changed(id)) => {
-
                let repo = context.repository();
-
                if let Some(issue) = cob::issue::find(repo, &id)? {
-
                    self.issue = Some((id, issue.clone()));
-
                    let comments = issue.comments().collect::<Vec<_>>();
-
                    let details = ui::details(
-
                        context,
-
                        theme,
-
                        (id, issue.clone()),
-
                        comments.first().copied(),
-
                    )
-
                    .to_boxed();
-
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
                }
-
            }
-
            Message::Issue(IssueMessage::Focus(cid)) => {
-
                self.activate(app, cid)?;
-
                self.update_shortcuts(app, self.active_component.clone())?;
-
            }
-
            Message::Issue(IssueMessage::OpenForm) => {
-
                let new_form = ui::new_form(context, theme).to_boxed();
-
                let list = ui::list(context, theme, None).to_boxed();
-

-
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-
                app.remount(Cid::Issue(IssueCid::Form), new_form, vec![])?;
-
                app.active(&Cid::Issue(IssueCid::Form))?;
-

-
                app.unsubscribe(&Cid::GlobalListener, subscription::global_clause())?;
-

-
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::Form))));
-
            }
-
            Message::Issue(IssueMessage::HideForm) => {
-
                app.umount(&Cid::Issue(IssueCid::Form))?;
-

-
                let list = ui::list(context, theme, self.issue.clone()).to_boxed();
-
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-

-
                app.subscribe(
-
                    &Cid::GlobalListener,
-
                    Sub::new(subscription::global_clause(), SubClause::Always),
-
                )?;
-

-
                if self.issue.is_none() {
-
                    return Ok(Some(Message::Issue(IssueMessage::Leave(None))));
-
                }
-
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::List))));
-
            }
-
            Message::FormSubmitted(id) => {
-
                if id == ui::FORM_ID_EDIT {
-
                    let state = app.state(&Cid::Issue(IssueCid::Form))?;
-
                    if let State::Linked(mut states) = state {
-
                        let mut missing_values = vec![];
-

-
                        let title = match states.front() {
-
                            Some(State::One(StateValue::String(title))) if !title.is_empty() => {
-
                                Some(title.clone())
-
                            }
-
                            _ => None,
-
                        };
-
                        states.pop_front();
-

-
                        let tags = match states.front() {
-
                            Some(State::One(StateValue::String(tags))) => Some(tags.clone()),
-
                            _ => Some(String::from("[]")),
-
                        };
-
                        states.pop_front();
-

-
                        let assignees = match states.front() {
-
                            Some(State::One(StateValue::String(assignees))) => {
-
                                Some(assignees.clone())
-
                            }
-
                            _ => Some(String::from("[]")),
-
                        };
-
                        states.pop_front();
-

-
                        let description = match states.front() {
-
                            Some(State::One(StateValue::String(description)))
-
                                if !description.is_empty() =>
-
                            {
-
                                Some(description.clone())
-
                            }
-
                            _ => None,
-
                        };
-
                        states.pop_front();
-

-
                        if title.is_none() {
-
                            missing_values.push("title");
-
                        }
-
                        if description.is_none() {
-
                            missing_values.push("description");
-
                        }
-

-
                        // show error popup if missing.
-
                        if !missing_values.is_empty() {
-
                            let error = format!("Missing fields: {:?}", missing_values);
-
                            return Ok(Some(Message::Popup(PopupMessage::Error(error))));
-
                        } else {
-
                            return Ok(Some(Message::Issue(IssueMessage::Cob(
-
                                IssueCobMessage::Create {
-
                                    title: title.unwrap(),
-
                                    tags: tags.unwrap(),
-
                                    assignees: assignees.unwrap(),
-
                                    description: description.unwrap(),
-
                                },
-
                            ))));
-
                        }
-
                    }
-
                }
-
            }
-
            _ => {}
-
        }
-

-
        self.update_context(app, context, theme, self.active_component.clone())?;
-

-
        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::issue_page(area, shortcuts_h);
-

-
        app.view(&Cid::Issue(IssueCid::Header), frame, layout.header);
-
        app.view(&Cid::Issue(IssueCid::List), frame, layout.left);
-

-
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
-
            app.view(&Cid::Issue(IssueCid::Form), frame, layout.right);
-
        } else if app.mounted(&Cid::Issue(IssueCid::Details)) {
-
            app.view(&Cid::Issue(IssueCid::Details), frame, layout.right);
-
        }
-

-
        app.view(&Cid::Issue(IssueCid::Context), frame, layout.context);
-
        app.view(&Cid::Issue(IssueCid::Shortcuts), frame, layout.shortcuts);
-
    }
-

-
    fn subscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        Ok(())
-
    }
-

-
    fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        Ok(())
-
    }
-
}
deleted src/issue/app/ui.rs
@@ -1,436 +0,0 @@
-
use radicle::cob::thread::Comment;
-
use radicle::cob::thread::CommentId;
-

-
use radicle::cob::issue::Issue;
-
use radicle::cob::issue::IssueId;
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use radicle_tui as tui;
-

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

-
use tui::ui::widget::container::{Container, Tabs};
-
use tui::ui::widget::context::{ContextBar, Progress};
-
use tui::ui::widget::form::{Form, TextArea, TextField};
-
use tui::ui::widget::label::Textarea;
-
use tui::ui::widget::list::{ColumnWidth, List, Property, Table};
-

-
pub const FORM_ID_EDIT: &str = "edit-form";
-

-
pub struct IssueBrowser {
-
    items: Vec<IssueItem>,
-
    table: Widget<Table<IssueItem, 7>>,
-
}
-

-
impl IssueBrowser {
-
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
-
        let header = [
-
            tui::ui::label(" ● "),
-
            tui::ui::label("ID"),
-
            tui::ui::label("Title"),
-
            tui::ui::label("Author"),
-
            tui::ui::label("Tags"),
-
            tui::ui::label("Assignees"),
-
            tui::ui::label("Opened"),
-
        ];
-

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

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

-
        for (id, issue) in context.issues() {
-
            if let Ok(item) = IssueItem::try_from((context.profile(), repo, *id, issue.clone())) {
-
                items.push(item);
-
            }
-
        }
-

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

-
        let selected = match selected {
-
            Some((id, issue)) => Some(IssueItem::from((context.profile(), repo, id, issue))),
-
            _ => 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<IssueItem> {
-
        &self.items
-
    }
-
}
-

-
impl WidgetComponent for IssueBrowser {
-
    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 struct LargeList {
-
    items: Vec<IssueItem>,
-
    list: Widget<Container>,
-
}
-

-
impl LargeList {
-
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
-
        let repo = context.repository();
-

-
        let mut items = context
-
            .issues()
-
            .iter()
-
            .map(|(id, issue)| IssueItem::from((context.profile(), repo, *id, issue.clone())))
-
            .collect::<Vec<_>>();
-

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

-
        let selected =
-
            selected.map(|(id, issue)| IssueItem::from((context.profile(), repo, id, issue)));
-

-
        let list = Widget::new(List::new(&items, selected, theme.clone()))
-
            .highlight(theme.colors.item_list_highlighted_bg);
-

-
        let container = tui::ui::container(theme, list.to_boxed());
-

-
        Self {
-
            items,
-
            list: container,
-
        }
-
    }
-

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

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

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

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

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

-
pub struct IssueHeader {
-
    container: Widget<Container>,
-
}
-

-
impl IssueHeader {
-
    pub fn new(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Self {
-
        let repo = context.repository();
-

-
        let (id, issue) = issue;
-
        let by_you = *issue.author().id() == context.profile().did();
-
        let item = IssueItem::from((context.profile(), repo, id, issue.clone()));
-

-
        let title = Property::new(
-
            tui::ui::label("Title").foreground(theme.colors.property_name_fg),
-
            tui::ui::label(item.title()).foreground(theme.colors.browser_list_title),
-
        );
-

-
        let author = Property::new(
-
            tui::ui::label("Author").foreground(theme.colors.property_name_fg),
-
            tui::ui::label(&cob::format_author(issue.author().id(), by_you))
-
                .foreground(theme.colors.browser_list_author),
-
        );
-

-
        let issue_id = Property::new(
-
            tui::ui::label("Issue").foreground(theme.colors.property_name_fg),
-
            tui::ui::label(&id.to_string()).foreground(theme.colors.browser_list_description),
-
        );
-

-
        let labels = Property::new(
-
            tui::ui::label("Labels").foreground(theme.colors.property_name_fg),
-
            tui::ui::label(&cob::format_labels(item.labels()))
-
                .foreground(theme.colors.browser_list_labels),
-
        );
-

-
        let assignees = Property::new(
-
            tui::ui::label("Assignees").foreground(theme.colors.property_name_fg),
-
            tui::ui::label(&cob::format_assignees(
-
                &item
-
                    .assignees()
-
                    .iter()
-
                    .map(|item| (item.did(), item.is_you()))
-
                    .collect::<Vec<_>>(),
-
            ))
-
            .foreground(theme.colors.browser_list_author),
-
        );
-

-
        let state = Property::new(
-
            tui::ui::label("Status").foreground(theme.colors.property_name_fg),
-
            tui::ui::label(&item.state().to_string()).foreground(theme.colors.browser_list_title),
-
        );
-

-
        let table = tui::ui::property_table(
-
            theme,
-
            vec![
-
                Widget::new(title),
-
                Widget::new(issue_id),
-
                Widget::new(author),
-
                Widget::new(labels),
-
                Widget::new(assignees),
-
                Widget::new(state),
-
            ],
-
        );
-
        let container = tui::ui::container(theme, table.to_boxed());
-

-
        Self { container }
-
    }
-
}
-

-
impl WidgetComponent for IssueHeader {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        self.container.view(frame, area);
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct IssueDetails {
-
    header: Widget<IssueHeader>,
-
    description: Widget<CommentBody>,
-
}
-

-
impl IssueDetails {
-
    pub fn new(
-
        context: &Context,
-
        theme: &Theme,
-
        issue: (IssueId, Issue),
-
        description: Option<(&CommentId, &Comment)>,
-
    ) -> Self {
-
        Self {
-
            header: header(context, theme, issue),
-
            description: self::description(context, theme, description),
-
        }
-
    }
-
}
-

-
impl WidgetComponent for IssueDetails {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints([Constraint::Length(8), Constraint::Min(1)])
-
            .split(area);
-

-
        self.header.view(frame, layout[0]);
-

-
        self.description
-
            .attr(Attribute::Focus, AttrValue::Flag(focus));
-
        self.description.view(frame, layout[1]);
-
    }
-

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

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

-
pub struct CommentBody {
-
    textarea: Widget<Container>,
-
}
-

-
impl CommentBody {
-
    pub fn new(_context: &Context, theme: &Theme, comment: Option<(&CommentId, &Comment)>) -> Self {
-
        let content = match comment {
-
            Some((_, comment)) => comment.body().to_string(),
-
            None => String::new(),
-
        };
-
        let textarea = Widget::new(Textarea::new(theme.clone()))
-
            .content(AttrValue::String(content))
-
            .foreground(theme.colors.default_fg);
-

-
        let textarea = tui::ui::container(theme, textarea.to_boxed());
-

-
        Self { textarea }
-
    }
-
}
-

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

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

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

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

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

-
pub fn list(
-
    context: &Context,
-
    theme: &Theme,
-
    issue: Option<(IssueId, Issue)>,
-
) -> Widget<LargeList> {
-
    let list = LargeList::new(context, theme, issue);
-

-
    Widget::new(list)
-
}
-

-
pub fn header(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<IssueHeader> {
-
    let header = IssueHeader::new(context, theme, issue);
-
    Widget::new(header)
-
}
-

-
pub fn description(
-
    context: &Context,
-
    theme: &Theme,
-
    comment: Option<(&CommentId, &Comment)>,
-
) -> Widget<CommentBody> {
-
    let body = CommentBody::new(context, theme, comment);
-
    Widget::new(body)
-
}
-

-
pub fn new_form(_context: &Context, theme: &Theme) -> Widget<Form> {
-
    use tuirealm::props::Layout;
-

-
    let title = Widget::new(TextField::new(theme.clone(), "Title")).to_boxed();
-
    let tags = Widget::new(TextField::new(theme.clone(), "Labels (bug, ...)")).to_boxed();
-
    let assignees = Widget::new(TextField::new(
-
        theme.clone(),
-
        "Assignees (z6MkvAdxCp1oLVVTsqYvev9YrhSN3gBQNUSM45hhy4pgkexk, ...)",
-
    ))
-
    .to_boxed();
-
    let description = Widget::new(TextArea::new(theme.clone(), "Description")).to_boxed();
-
    let inputs: Vec<Box<dyn MockComponent>> = vec![title, tags, assignees, description];
-

-
    let layout = Layout::default().constraints(
-
        [
-
            Constraint::Length(3),
-
            Constraint::Length(3),
-
            Constraint::Length(3),
-
            Constraint::Min(3),
-
        ]
-
        .as_ref(),
-
    );
-

-
    Widget::new(Form::new(theme.clone(), inputs))
-
        .custom(Form::PROP_ID, AttrValue::String(String::from(FORM_ID_EDIT)))
-
        .layout(layout)
-
}
-

-
pub fn details(
-
    context: &Context,
-
    theme: &Theme,
-
    issue: (IssueId, Issue),
-
    comment: Option<(&CommentId, &Comment)>,
-
) -> Widget<IssueDetails> {
-
    let discussion = IssueDetails::new(context, theme, issue, comment);
-
    Widget::new(discussion)
-
}
-

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

-
    let issues = context.issues();
-
    let open = issues
-
        .iter()
-
        .filter(|issue| *issue.1.state() == State::Open)
-
        .collect::<Vec<_>>()
-
        .len();
-
    let closed = issues
-
        .iter()
-
        .filter(|issue| *issue.1.state() != State::Open)
-
        .collect::<Vec<_>>()
-
        .len();
-

-
    tui::ui::widget::context::bar(
-
        theme,
-
        "Browse",
-
        "",
-
        "",
-
        &format!("{open} open | {closed} closed"),
-
        &progress.to_string(),
-
    )
-
}
-

-
pub fn description_context(
-
    _context: &Context,
-
    theme: &Theme,
-
    progress: Progress,
-
) -> Widget<ContextBar> {
-
    tui::ui::widget::context::bar(theme, "Show", "", "", "", &progress.to_string())
-
}
-

-
pub fn form_context(_context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
-
    tui::ui::widget::context::bar(theme, "Open", "", "", "", &progress.to_string())
-
        .custom(ContextBar::PROP_EDIT_MODE, AttrValue::Flag(true))
-
}
-

-
pub fn issues(
-
    context: &Context,
-
    theme: &Theme,
-
    selected: Option<(IssueId, Issue)>,
-
) -> Widget<IssueBrowser> {
-
    Widget::new(IssueBrowser::new(context, theme, selected))
-
}
deleted src/issue/main.rs
@@ -1,87 +0,0 @@
-
mod app;
-

-
use std::process;
-

-
use anyhow::anyhow;
-

-
use radicle::profile;
-

-
use log::info;
-
use log::LevelFilter;
-

-
use radicle_term as term;
-
use radicle_tui as tui;
-

-
use tui::context;
-
use tui::Window;
-

-
pub const NAME: &str = "rad-issue-tui";
-
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
-
pub const GIT_HEAD: &str = env!("GIT_HEAD");
-
pub const FPS: u64 = 60;
-

-
pub const HELP: &str = r#"
-
Usage
-

-
    rad-issue-tui [<option>...]
-

-
Options
-

-
    --version       Print version
-
    --help          Print help
-

-
"#;
-

-
struct Options;
-

-
impl Options {
-
    #[allow(clippy::never_loop)]
-
    fn from_env() -> Result<Self, anyhow::Error> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_env();
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("version") => {
-
                    println!("{NAME} {VERSION}+{GIT_HEAD}");
-
                    process::exit(0);
-
                }
-
                Long("help") | Short('h') => {
-
                    println!("{HELP}");
-
                    process::exit(0);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok(Self {})
-
    }
-
}
-

-
fn execute() -> anyhow::Result<()> {
-
    let _ = Options::from_env()?;
-

-
    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)?;
-

-
    let logfile = format!(
-
        "{}/rad-issue-tui.log",
-
        profile::home()?.path().to_string_lossy()
-
    );
-
    simple_logging::log_to_file(logfile, LevelFilter::Info)?;
-
    info!("Launching window...");
-

-
    let mut window = Window::default();
-
    window.run(&mut app::App::new(context), 1000 / FPS)?;
-

-
    Ok(())
-
}
-

-
fn main() {
-
    if let Err(err) = execute() {
-
        term::error(format!("Error: rad-issue-tui: {err}"));
-
        process::exit(1);
-
    }
-
}
deleted src/patch.rs
@@ -1,397 +0,0 @@
-
pub mod event;
-
pub mod page;
-
pub mod subscription;
-

-
use anyhow::Result;
-

-
use radicle::cob::issue::IssueId;
-
use radicle::cob::patch::PatchId;
-
use radicle::identity::{Id, Project};
-
use radicle::prelude::Signer;
-
use radicle::profile::Profile;
-

-
use radicle_tui::ui::widget;
-
use tuirealm::application::PollStrategy;
-
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui::ui::context::Context;
-
use radicle_tui::ui::theme::{self, Theme};
-
use radicle_tui::Tui;
-
use radicle_tui::{cob, ui};
-

-
use page::{HomeView, PatchView};
-

-
use self::page::{IssuePage, PageStack};
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum HomeCid {
-
    Header,
-
    Dashboard,
-
    IssueBrowser,
-
    PatchBrowser,
-
    Context,
-
    Shortcuts,
-
}
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum PatchCid {
-
    Header,
-
    Activity,
-
    Files,
-
    Context,
-
    Shortcuts,
-
}
-

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum IssueCid {
-
    Header,
-
    List,
-
    Details,
-
    Context,
-
    Form,
-
    Shortcuts,
-
}
-

-
/// All component ids known to this application.
-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum Cid {
-
    Home(HomeCid),
-
    Issue(IssueCid),
-
    Patch(PatchCid),
-
    GlobalListener,
-
    Popup,
-
}
-

-
/// Messages handled by this application.
-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum HomeMessage {
-
    RefreshIssues(Option<IssueId>),
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueCobMessage {
-
    Create {
-
        title: String,
-
        tags: String,
-
        assignees: String,
-
        description: String,
-
    },
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueMessage {
-
    Show(Option<IssueId>),
-
    Changed(IssueId),
-
    Focus(IssueCid),
-
    Created(IssueId),
-
    Cob(IssueCobMessage),
-
    OpenForm,
-
    HideForm,
-
    Leave(Option<IssueId>),
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum PatchMessage {
-
    Show(PatchId),
-
    Leave,
-
}
-

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

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum Message {
-
    Home(HomeMessage),
-
    Issue(IssueMessage),
-
    Patch(PatchMessage),
-
    NavigationChanged(u16),
-
    FormSubmitted(String),
-
    Popup(PopupMessage),
-
    Tick,
-
    Quit,
-
    Batch(Vec<Message>),
-
}
-

-
#[allow(dead_code)]
-
pub struct App {
-
    context: Context,
-
    pages: PageStack,
-
    theme: Theme,
-
    quit: bool,
-
}
-

-
/// Creates a new application using a tui-realm-application, mounts all
-
/// components and sets focus to a default one.
-
impl App {
-
    pub fn new(profile: Profile, id: Id, project: Project, signer: Box<dyn Signer>) -> Self {
-
        Self {
-
            context: Context::new(profile, id, project, signer),
-
            pages: PageStack::default(),
-
            theme: theme::default_dark(),
-
            quit: false,
-
        }
-
    }
-

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

-
        Ok(())
-
    }
-

-
    fn view_patch(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: PatchId,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-

-
        if let Some(patch) = cob::patch::find(repo, &id)? {
-
            let view = Box::new(PatchView::new(theme.clone(), (id, patch)));
-
            self.pages.push(view, app, &self.context, theme)?;
-

-
            Ok(())
-
        } else {
-
            Err(anyhow::anyhow!(
-
                "Could not mount 'page::PatchView'. Patch not found."
-
            ))
-
        }
-
    }
-

-
    fn view_issue(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: Option<IssueId>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-
        match id {
-
            Some(id) => {
-
                if let Some(issue) = cob::issue::find(repo, &id)? {
-
                    let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue))));
-
                    self.pages.push(view, app, &self.context, theme)?;
-

-
                    Ok(())
-
                } else {
-
                    Err(anyhow::anyhow!(
-
                        "Could not mount 'page::IssueView'. Issue not found."
-
                    ))
-
                }
-
            }
-
            None => {
-
                let view = Box::new(IssuePage::new(&self.context, theme, None));
-
                self.pages.push(view, 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::Issue(IssueMessage::Cob(IssueCobMessage::Create {
-
                title,
-
                tags,
-
                assignees,
-
                description,
-
            })) => match self.create_issue(title, description, tags, assignees) {
-
                Ok(id) => {
-
                    self.context.reload();
-

-
                    Ok(Some(Message::Batch(vec![
-
                        Message::Issue(IssueMessage::HideForm),
-
                        Message::Issue(IssueMessage::Created(id)),
-
                    ])))
-
                }
-
                Err(err) => {
-
                    let error = format!("{:?}", err);
-
                    self.show_error_popup(app, &theme, &error)?;
-

-
                    Ok(None)
-
                }
-
            },
-
            Message::Issue(IssueMessage::Show(id)) => {
-
                self.view_issue(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Issue(IssueMessage::Leave(id)) => {
-
                self.pages.pop(app)?;
-
                Ok(Some(Message::Home(HomeMessage::RefreshIssues(id))))
-
            }
-
            Message::Patch(PatchMessage::Show(id)) => {
-
                self.view_patch(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Patch(PatchMessage::Leave) => {
-
                self.pages.pop(app)?;
-
                Ok(None)
-
            }
-
            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 => {
-
                self.quit = true;
-
                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 = widget::common::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 = widget::common::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 = widget::common::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(())
-
    }
-

-
    fn create_issue(
-
        &mut self,
-
        title: String,
-
        description: String,
-
        labels: String,
-
        assignees: String,
-
    ) -> Result<IssueId> {
-
        let repository = self.context.repository();
-
        let signer = self.context.signer();
-

-
        let labels = cob::parse_labels(labels)?;
-
        let assignees = cob::parse_assignees(assignees)?;
-

-
        cob::issue::create(
-
            repository,
-
            signer,
-
            title,
-
            description,
-
            labels.as_slice(),
-
            assignees.as_slice(),
-
        )
-
    }
-
}
-

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

-
        // Add global key listener and subscribe to key events
-
        let global = ui::widget::common::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 quit(&self) -> bool {
-
        self.quit
-
    }
-
}
deleted src/patch/app.rs
@@ -1,274 +0,0 @@
-
mod event;
-
mod page;
-
mod ui;
-

-
use std::hash::Hash;
-

-
use anyhow::Result;
-

-
use radicle::cob::patch::PatchId;
-

-
use tuirealm::application::PollStrategy;
-
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 page::{ListView, PatchView};
-

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

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum PatchCid {
-
    Header,
-
    Activity,
-
    Files,
-
    Context,
-
    Shortcuts,
-
}
-

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

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum PatchMessage {
-
    Show(PatchId),
-
    Leave,
-
}
-

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

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Message {
-
    Patch(PatchMessage),
-
    NavigationChanged(u16),
-
    FormSubmitted(String),
-
    Popup(PopupMessage),
-
    #[default]
-
    Tick,
-
    Quit,
-
    Batch(Vec<Message>),
-
}
-

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

-
/// 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,
-
        }
-
    }
-

-
    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 view_patch(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: PatchId,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-

-
        if let Some(patch) = cob::patch::find(repo, &id)? {
-
            let view = Box::new(PatchView::new(theme.clone(), (id, patch)));
-
            self.pages.push(view, app, &self.context, theme)?;
-

-
            Ok(())
-
        } else {
-
            Err(anyhow::anyhow!(
-
                "Could not mount 'page::PatchView'. Patch not found."
-
            ))
-
        }
-
    }
-

-
    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::Patch(PatchMessage::Show(id)) => {
-
                self.view_patch(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Patch(PatchMessage::Leave) => {
-
                self.pages.pop(app)?;
-
                Ok(None)
-
            }
-
            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 => {
-
                self.quit = true;
-
                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 quit(&self) -> bool {
-
        self.quit
-
    }
-
}
deleted src/patch/app/event.rs
@@ -1,139 +0,0 @@
-
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, PatchMessage, 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,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
-
                match self.perform(Cmd::Move(MoveDirection::Right)) {
-
                    CmdResult::Changed(State::One(StateValue::U16(index))) => {
-
                        Some(Message::NavigationChanged(index))
-
                    }
-
                    _ => 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::Patch(PatchMessage::Show(item.id().to_owned())))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::Activity> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Patch(PatchMessage::Leave))
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::Files> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Patch(PatchMessage::Leave))
-
            }
-
            _ => 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<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
-
    }
-
}
deleted src/patch/app/page.rs
@@ -1,326 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

-
use radicle::cob::patch::{Patch, PatchId};
-

-
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, PatchCid};
-

-
///
-
/// 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(())
-
    }
-
}
-

-
///
-
/// Patch detail page
-
///
-
pub struct PatchView {
-
    active_component: PatchCid,
-
    patch: (PatchId, Patch),
-
    shortcuts: HashMap<PatchCid, Widget<Shortcuts>>,
-
}
-

-
impl PatchView {
-
    pub fn new(theme: Theme, patch: (PatchId, Patch)) -> Self {
-
        let shortcuts = Self::build_shortcuts(&theme);
-
        PatchView {
-
            active_component: PatchCid::Activity,
-
            patch,
-
            shortcuts,
-
        }
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<PatchCid, Widget<Shortcuts>> {
-
        [
-
            (
-
                PatchCid::Activity,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "tab", "section"),
-
                        tui::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                PatchCid::Files,
-
                tui::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::ui::shortcut(theme, "esc", "back"),
-
                        tui::ui::shortcut(theme, "tab", "section"),
-
                        tui::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
        ]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-

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

-
impl ViewPage<Cid, Message> for PatchView {
-
    fn mount(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let navigation = ui::navigation(theme);
-
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
-
        let activity = ui::activity(theme).to_boxed();
-
        let files = ui::files(theme).to_boxed();
-
        let context = ui::context(context, theme, self.patch.clone()).to_boxed();
-

-
        app.remount(Cid::Patch(PatchCid::Header), header, vec![])?;
-
        app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?;
-
        app.remount(Cid::Patch(PatchCid::Files), files, vec![])?;
-
        app.remount(Cid::Patch(PatchCid::Context), context, vec![])?;
-

-
        let active_component = Cid::Patch(self.active_component.clone());
-
        app.active(&active_component)?;
-
        self.update_shortcuts(app, self.active_component.clone())?;
-

-
        Ok(())
-
    }
-

-
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.umount(&Cid::Patch(PatchCid::Header))?;
-
        app.umount(&Cid::Patch(PatchCid::Activity))?;
-
        app.umount(&Cid::Patch(PatchCid::Files))?;
-
        app.umount(&Cid::Patch(PatchCid::Context))?;
-
        app.umount(&Cid::Patch(PatchCid::Shortcuts))?;
-
        Ok(())
-
    }
-

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        _context: &Context,
-
        _theme: &Theme,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        if let Message::NavigationChanged(index) = message {
-
            self.active_component = PatchCid::from(index as usize);
-

-
            let active_component = Cid::Patch(self.active_component.clone());
-
            app.active(&active_component)?;
-
            self.update_shortcuts(app, self.active_component.clone())?;
-
        }
-

-
        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::Patch(PatchCid::Header), frame, layout.navigation);
-
        app.view(
-
            &Cid::Patch(self.active_component.clone()),
-
            frame,
-
            layout.component,
-
        );
-
        app.view(&Cid::Patch(PatchCid::Context), frame, layout.context);
-
        app.view(&Cid::Patch(PatchCid::Shortcuts), frame, layout.shortcuts);
-
    }
-

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

-
        Ok(())
-
    }
-

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

-
        Ok(())
-
    }
-
}
-

-
impl From<usize> for PatchCid {
-
    fn from(index: usize) -> Self {
-
        match index {
-
            0 => PatchCid::Activity,
-
            1 => PatchCid::Files,
-
            _ => PatchCid::Activity,
-
        }
-
    }
-
}
deleted src/patch/app/ui.rs
@@ -1,242 +0,0 @@
-
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;
-
use tui::ui::cob::PatchItem;
-
use tui::ui::layout;
-
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::label::Label;
-
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 struct Activity {
-
    label: Widget<Label>,
-
}
-

-
impl Activity {
-
    pub fn new(label: Widget<Label>) -> Self {
-
        Self { label }
-
    }
-
}
-

-
impl WidgetComponent for Activity {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let label_w = self
-
            .label
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(1))
-
            .unwrap_size();
-

-
        self.label
-
            .view(frame, layout::centered_label(label_w, area));
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct Files {
-
    label: Widget<Label>,
-
}
-

-
impl Files {
-
    pub fn new(label: Widget<Label>) -> Self {
-
        Self { label }
-
    }
-
}
-

-
impl WidgetComponent for Files {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let label_w = self
-
            .label
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(1))
-
            .unwrap_size();
-

-
        self.label
-
            .view(frame, layout::centered_label(label_w, area));
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
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 navigation(theme: &Theme) -> Widget<Tabs> {
-
    tui::ui::tabs(
-
        theme,
-
        vec![
-
            tui::ui::reversable_label("Activity").foreground(theme.colors.tabs_highlighted_fg),
-
            tui::ui::reversable_label("Files").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 activity(theme: &Theme) -> Widget<Activity> {
-
    let not_implemented = tui::ui::label("not implemented").foreground(theme.colors.default_fg);
-
    let activity = Activity::new(not_implemented);
-

-
    Widget::new(activity)
-
}
-

-
pub fn files(theme: &Theme) -> Widget<Files> {
-
    let not_implemented = tui::ui::label("not implemented").foreground(theme.colors.default_fg);
-
    let files = Files::new(not_implemented);
-

-
    Widget::new(files)
-
}
-

-
pub fn context(context: &Context, theme: &Theme, patch: (PatchId, Patch)) -> Widget<ContextBar> {
-
    let (id, patch) = patch;
-
    let (_, rev) = patch.latest();
-
    let is_you = *patch.author().id() == context.profile().did();
-

-
    let id = cob::format::cob(&id);
-
    let title = patch.title();
-
    let author = cob::format_author(patch.author().id(), is_you);
-
    let comments = rev.discussion().len();
-

-
    tui::ui::widget::context::bar(theme, "Patch", &id, title, &author, &comments.to_string())
-
}
-

-
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(),
-
    )
-
}
deleted src/patch/main.rs
@@ -1,87 +0,0 @@
-
mod app;
-

-
use std::process;
-

-
use anyhow::anyhow;
-

-
use radicle::profile;
-

-
use log::info;
-
use log::LevelFilter;
-

-
use radicle_term as term;
-
use radicle_tui as tui;
-

-
use tui::context;
-
use tui::Window;
-

-
pub const NAME: &str = "rad-patch-tui";
-
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
-
pub const GIT_HEAD: &str = env!("GIT_HEAD");
-
pub const FPS: u64 = 60;
-

-
pub const HELP: &str = r#"
-
Usage
-

-
    rad-patch-tui [<option>...]
-

-
Options
-

-
    --version       Print version
-
    --help          Print help
-

-
"#;
-

-
struct Options;
-

-
impl Options {
-
    #[allow(clippy::never_loop)]
-
    fn from_env() -> Result<Self, anyhow::Error> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_env();
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("version") => {
-
                    println!("{NAME} {VERSION}+{GIT_HEAD}");
-
                    process::exit(0);
-
                }
-
                Long("help") | Short('h') => {
-
                    println!("{HELP}");
-
                    process::exit(0);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        Ok(Self {})
-
    }
-
}
-

-
fn execute() -> anyhow::Result<()> {
-
    let _ = Options::from_env()?;
-

-
    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)?;
-

-
    let logfile = format!(
-
        "{}/rad-patch-tui.log",
-
        profile::home()?.path().to_string_lossy()
-
    );
-
    simple_logging::log_to_file(logfile, LevelFilter::Info)?;
-
    info!("Launching window...");
-

-
    let mut window = Window::default();
-
    window.run(&mut app::App::new(context), 1000 / FPS)?;
-

-
    Ok(())
-
}
-

-
fn main() {
-
    if let Err(err) = execute() {
-
        term::error(format!("Error: rad-patch-tui: {err}"));
-
        process::exit(1);
-
    }
-
}