diff --git a/Cargo.toml b/Cargo.toml index 589edb8b..d59c4cf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,12 @@ edition = "2021" build = "build.rs" [[bin]] -name = "radicle-tui" -path = "src/main.rs" +name = "rad-issue-tui" +path = "src/issue/main.rs" + +[[bin]] +name = "rad-patch-tui" +path = "src/patch/main.rs" [dependencies] anyhow = { version = "1" } diff --git a/src/issue/app.rs b/src/issue/app.rs new file mode 100644 index 00000000..481a50d5 --- /dev/null +++ b/src/issue/app.rs @@ -0,0 +1,397 @@ +mod event; +mod page; +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), +} + +#[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), + Changed(IssueId), + Focus(IssueCid), + Created(IssueId), + Cob(IssueCobMessage), + OpenForm, + HideForm, + Leave(Option), +} + +#[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), +} + +#[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) -> 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, + 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, + 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, + id: Option, + 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, + message: Message, + ) -> Result> { + 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, + 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, + 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, + 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) -> Result<()> { + app.blur()?; + app.umount(&Cid::Popup)?; + + Ok(()) + } + + fn create_issue( + &mut self, + title: String, + description: String, + labels: String, + assignees: String, + ) -> Result { + 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 for App { + fn init(&mut self, app: &mut Application) -> 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, 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) -> Result { + 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 + } +} diff --git a/src/issue/event.rs b/src/issue/app/event.rs similarity index 100% rename from src/issue/event.rs rename to src/issue/app/event.rs diff --git a/src/issue/page.rs b/src/issue/app/page.rs similarity index 100% rename from src/issue/page.rs rename to src/issue/app/page.rs diff --git a/src/issue/subscription.rs b/src/issue/app/subscription.rs similarity index 100% rename from src/issue/subscription.rs rename to src/issue/app/subscription.rs diff --git a/src/main.rs b/src/issue/main.rs similarity index 91% rename from src/main.rs rename to src/issue/main.rs index 8f206f71..f9169a43 100644 --- a/src/main.rs +++ b/src/issue/main.rs @@ -14,9 +14,9 @@ use radicle_tui::Window; use radicle::storage::ReadStorage; use term::{passphrase, spinner}; -mod patch; +mod app; -pub const NAME: &str = "radicle-tui"; +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; @@ -24,7 +24,7 @@ pub const FPS: u64 = 60; pub const HELP: &str = r#" Usage - radicle-tui [