diff --git a/bin/commands/issue.rs b/bin/commands/issue.rs index ecda0112..352bd9c6 100644 --- a/bin/commands/issue.rs +++ b/bin/commands/issue.rs @@ -1,17 +1,22 @@ +#[path = "issue/common.rs"] +mod common; +#[cfg(feature = "flux")] +#[path = "issue/flux.rs"] +mod flux; #[cfg(feature = "realm")] #[path = "issue/realm.rs"] mod realm; -#[path = "issue/common.rs"] -mod common; - use std::ffi::OsString; use anyhow::anyhow; +use radicle::storage::ReadStorage; + use radicle_tui as tui; use tui::common::cob::issue::{self, State}; +use tui::common::log; use crate::terminal; use crate::terminal::args::{Args, Error, Help}; @@ -150,10 +155,33 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> } #[cfg(feature = "flux")] -pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> { +#[tokio::main] +pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> { + let (_, rid) = radicle::rad::cwd() + .map_err(|_| anyhow!("this command must be run in the context of a project"))?; + match options.op { - Operation::Select { opts: _ } => { - anyhow::bail!("operation not yet implemented with flux") + Operation::Select { opts } => { + let profile = terminal::profile()?; + let repository = profile.storage.repository(rid).unwrap(); + + log::enable(&profile, "inbox", "select")?; + + let context = flux::select::Context { + profile, + repository, + mode: opts.mode, + filter: opts.filter.clone(), + }; + let output = flux::select::App::new(context).run().await?; + + let output = output + .map(|o| serde_json::to_string(&o).unwrap_or_default()) + .unwrap_or_default(); + + eprint!("{output}"); } } + + Ok(()) } diff --git a/bin/commands/issue/common.rs b/bin/commands/issue/common.rs index 678697d9..ff4b30fd 100644 --- a/bin/commands/issue/common.rs +++ b/bin/commands/issue/common.rs @@ -1,3 +1,7 @@ +use std::fmt::Display; + +use serde::Serialize; + /// The application's mode. It tells the application /// which widgets to render and which output to produce. /// Depends on CLI arguments given by the user. @@ -7,3 +11,32 @@ pub enum Mode { Operation, Id, } + +/// The selected issue operation returned by the operation +/// selection widget. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub enum IssueOperation { + Show, + Delete, + Edit, + Comment, +} + +impl Display for IssueOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IssueOperation::Show => { + write!(f, "show") + } + IssueOperation::Delete => { + write!(f, "delete") + } + IssueOperation::Edit => { + write!(f, "edit") + } + IssueOperation::Comment => { + write!(f, "comment") + } + } + } +} \ No newline at end of file diff --git a/bin/commands/issue/flux.rs b/bin/commands/issue/flux.rs new file mode 100644 index 00000000..ffc8d2d0 --- /dev/null +++ b/bin/commands/issue/flux.rs @@ -0,0 +1,2 @@ +#[path = "flux/select.rs"] +pub mod select; diff --git a/bin/commands/issue/flux/select.rs b/bin/commands/issue/flux/select.rs new file mode 100644 index 00000000..bec74ff8 --- /dev/null +++ b/bin/commands/issue/flux/select.rs @@ -0,0 +1,122 @@ +#[path = "select/ui.rs"] +mod ui; + +use anyhow::Result; + +use radicle::issue::IssueId; +use radicle::storage::git::Repository; +use radicle::Profile; + +use radicle_tui as tui; + +use tui::common::cob::issue::{self, Filter}; +use tui::flux::store::{State, Store}; +use tui::flux::termination::{self, Interrupted}; +use tui::flux::ui::cob::IssueItem; +use tui::flux::ui::Frontend; +use tui::Exit; + +use ui::ListPage; + +use super::super::common::Mode; + +type Selection = tui::Selection; + +pub struct Context { + pub profile: Profile, + pub repository: Repository, + pub mode: Mode, + pub filter: Filter, +} + +pub struct App { + context: Context, +} + +#[derive(Clone, Debug)] +pub struct IssuesState { + issues: Vec, + selected: Option, + mode: Mode, + filter: Filter, +} + +impl TryFrom<&Context> for IssuesState { + type Error = anyhow::Error; + + fn try_from(context: &Context) -> Result { + let issues = issue::all(&context.repository)?; + let issues = issues + .iter() + .filter(|(_, issue)| context.filter.matches(&context.profile, issue)); + + let mut items = vec![]; + + // Convert into UI items + for issue in issues { + if let Ok(item) = IssueItem::new(&context.profile, issue.clone()) { + items.push(item); + } + } + + // Apply sorting + items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + let selected = items.first().cloned(); + + Ok(Self { + issues: items, + selected, + mode: context.mode.clone(), + filter: context.filter.clone(), + }) + } +} + +pub enum Action { + Exit { selection: Option }, + Select { item: IssueItem }, +} + +impl State for IssuesState { + fn tick(&self) {} + + fn handle_action(&mut self, action: Action) -> Option> { + match action { + Action::Select { item } => { + self.selected = Some(item); + None + } + Action::Exit { selection } => Some(Exit { value: selection }), + } + } +} + +impl App { + pub fn new(context: Context) -> Self { + Self { context } + } + + pub async fn run(&self) -> Result> { + let (terminator, mut interrupt_rx) = termination::create_termination(); + let (store, state_rx) = Store::::new(); + let (frontend, action_rx) = Frontend::::new(); + let state = IssuesState::try_from(&self.context)?; + + tokio::try_join!( + store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()), + frontend.main_loop::( + state_rx, + interrupt_rx.resubscribe() + ), + )?; + + if let Ok(reason) = interrupt_rx.recv().await { + match reason { + Interrupted::User { payload } => Ok(payload), + Interrupted::OsSignal => anyhow::bail!("exited because of an os sig int"), + } + } else { + anyhow::bail!("exited because of an unexpected error"); + } + } +} diff --git a/bin/commands/issue/flux/select/ui.rs b/bin/commands/issue/flux/select/ui.rs new file mode 100644 index 00000000..10c039d8 --- /dev/null +++ b/bin/commands/issue/flux/select/ui.rs @@ -0,0 +1,278 @@ +use std::vec; + +use ratatui::style::Stylize; +use tokio::sync::mpsc::UnboundedSender; + +use termion::event::Key; + +use ratatui::backend::Backend; +use ratatui::layout::{Constraint, Rect}; + +use radicle_tui as tui; + +use tui::common::cob::issue::Filter; +use tui::flux::ui::cob::IssueItem; +use tui::flux::ui::span; +use tui::flux::ui::widget::{ + FooterProps, Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget, +}; +use tui::Selection; + +use crate::tui_issue::common::IssueOperation; +use crate::tui_issue::common::Mode; + +use super::{Action, IssuesState}; + +pub struct ListPageProps { + selected: Option, + mode: Mode, +} + +impl From<&IssuesState> for ListPageProps { + fn from(state: &IssuesState) -> Self { + Self { + selected: state.selected.clone(), + mode: state.mode.clone(), + } + } +} + +pub struct ListPage { + /// Action sender + pub action_tx: UnboundedSender, + /// State mapped props + props: ListPageProps, + /// Notification widget + issues: Issues, + /// Shortcut widget + shortcuts: Shortcuts, +} + +impl Widget for ListPage { + fn new(state: &IssuesState, action_tx: UnboundedSender) -> Self + where + Self: Sized, + { + Self { + action_tx: action_tx.clone(), + props: ListPageProps::from(state), + issues: Issues::new(state, action_tx.clone()), + shortcuts: Shortcuts::new(state, action_tx.clone()), + } + .move_with_state(state) + } + + fn move_with_state(self, state: &IssuesState) -> Self + where + Self: Sized, + { + ListPage { + issues: self.issues.move_with_state(state), + shortcuts: self.shortcuts.move_with_state(state), + props: ListPageProps::from(state), + ..self + } + } + + fn name(&self) -> &str { + "list-page" + } + + fn handle_key_event(&mut self, key: termion::event::Key) { + match key { + Key::Char('q') => { + let _ = self.action_tx.send(Action::Exit { selection: None }); + } + Key::Char('\n') => { + if let Some(selected) = &self.props.selected { + let operation = match self.props.mode { + Mode::Operation => Some(IssueOperation::Show.to_string()), + Mode::Id => None, + }; + let _ = self.action_tx.send(Action::Exit { + selection: Some(Selection { + operation, + ids: vec![selected.id], + args: vec![], + }), + }); + } + } + _ => { + >::handle_key_event(&mut self.issues, key); + } + } + } +} + +impl Render<()> for ListPage { + fn render(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) { + let area = frame.size(); + let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16); + + let shortcuts = match self.props.mode { + Mode::Id => vec![Shortcut::new("enter", "select"), Shortcut::new("q", "quit")], + Mode::Operation => vec![Shortcut::new("enter", "show"), Shortcut::new("q", "quit")], + }; + + self.issues.render::(frame, layout.component, ()); + self.shortcuts.render::( + frame, + layout.shortcuts, + ShortcutsProps { + shortcuts, + divider: '∙', + }, + ); + } +} + +struct IssuesProps { + issues: Vec, + filter: Filter, +} + +impl From<&IssuesState> for IssuesProps { + fn from(state: &IssuesState) -> Self { + Self { + issues: state.issues.clone(), + filter: state.filter.clone(), + } + } +} + +struct Issues { + /// Action sender + action_tx: UnboundedSender, + /// State mapped props + props: IssuesProps, + /// Notification table + table: Table, +} + +impl Widget for Issues { + fn new(state: &IssuesState, action_tx: UnboundedSender) -> Self { + Self { + action_tx: action_tx.clone(), + props: IssuesProps::from(state), + table: Table::new(state, action_tx.clone()), + } + } + + fn move_with_state(self, state: &IssuesState) -> Self + where + Self: Sized, + { + Self { + props: IssuesProps::from(state), + table: self.table.move_with_state(state), + ..self + } + } + + fn name(&self) -> &str { + "notification-list" + } + + fn handle_key_event(&mut self, key: Key) { + match key { + Key::Up => { + self.table.prev(); + + let selected = self + .table + .selected() + .and_then(|selected| self.props.issues.get(selected)); + + // TODO: propagate error + if let Some(notif) = selected { + let _ = self.action_tx.send(Action::Select { + item: notif.clone(), + }); + } + } + Key::Down => { + self.table.next(self.props.issues.len()); + + let selected = self + .table + .selected() + .and_then(|selected| self.props.issues.get(selected)); + + // TODO: propagate error + if let Some(notif) = selected { + let _ = self.action_tx.send(Action::Select { + item: notif.clone(), + }); + } + } + _ => {} + } + } +} + +impl Render<()> for Issues { + fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) { + let header = [ + String::from(" ● ").into(), + String::from("ID").into(), + String::from("Title").into(), + String::from("Author").into(), + String::from("").into(), + String::from("Labels").into(), + String::from("Assignees ").into(), + String::from("Opened").into(), + ]; + + let widths = [ + Constraint::Length(3), + Constraint::Length(8), + Constraint::Fill(5), + Constraint::Length(16), + Constraint::Length(16), + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Length(16), + ]; + + let progress = { + let step = self + .table + .selected() + .map(|selected| selected.saturating_add(1).to_string()) + .unwrap_or("-".to_string()); + let length = self.props.issues.len().to_string(); + + span::badge(format!("{}/{}", step, length)) + }; + + let footer = FooterProps { + cells: [ + span::badge("/".to_string()), + span::default(self.props.filter.to_string()).magenta().dim(), + String::from("").into(), + progress.clone(), + ] + .to_vec(), + widths: [ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Length(progress.width() as u16), + ] + .to_vec(), + }; + + self.table.render::( + frame, + area, + TableProps { + items: self.props.issues.to_vec(), + focus: false, + widths, + header, + footer: Some(footer), + }, + ); + } +}