Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
issue: Implement select app
Erik Kundt committed 2 years ago
commit 4b2eeef240f9297c3f3f37c449cd5669caf40efd
parent 937ae12bceafa7216e57e9fdb14ccdba414715d1
5 files changed +469 -6
modified 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(())
}
modified 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
added bin/commands/issue/flux.rs
@@ -0,0 +1,2 @@
+
#[path = "flux/select.rs"]
+
pub mod select;
added 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<IssueId>;
+

+
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<IssueItem>,
+
    selected: Option<IssueItem>,
+
    mode: Mode,
+
    filter: Filter,
+
}
+

+
impl TryFrom<&Context> for IssuesState {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        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<Selection> },
+
    Select { item: IssueItem },
+
}
+

+
impl State<Action, Selection> for IssuesState {
+
    fn tick(&self) {}
+

+
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
+
        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<Option<Selection>> {
+
        let (terminator, mut interrupt_rx) = termination::create_termination();
+
        let (store, state_rx) = Store::<Action, IssuesState, Selection>::new();
+
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let state = IssuesState::try_from(&self.context)?;
+

+
        tokio::try_join!(
+
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
+
            frontend.main_loop::<IssuesState, ListPage, Selection>(
+
                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");
+
        }
+
    }
+
}
added 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<IssueItem>,
+
    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<Action>,
+
    /// State mapped props
+
    props: ListPageProps,
+
    /// Notification widget
+
    issues: Issues,
+
    /// Shortcut widget
+
    shortcuts: Shortcuts<Action>,
+
}
+

+
impl Widget<IssuesState, Action> for ListPage {
+
    fn new(state: &IssuesState, action_tx: UnboundedSender<Action>) -> 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![],
+
                        }),
+
                    });
+
                }
+
            }
+
            _ => {
+
                <Issues as Widget<IssuesState, Action>>::handle_key_event(&mut self.issues, key);
+
            }
+
        }
+
    }
+
}
+

+
impl Render<()> for ListPage {
+
    fn render<B: Backend>(&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::<B>(frame, layout.component, ());
+
        self.shortcuts.render::<B>(
+
            frame,
+
            layout.shortcuts,
+
            ShortcutsProps {
+
                shortcuts,
+
                divider: '∙',
+
            },
+
        );
+
    }
+
}
+

+
struct IssuesProps {
+
    issues: Vec<IssueItem>,
+
    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<Action>,
+
    /// State mapped props
+
    props: IssuesProps,
+
    /// Notification table
+
    table: Table<Action>,
+
}
+

+
impl Widget<IssuesState, Action> for Issues {
+
    fn new(state: &IssuesState, action_tx: UnboundedSender<Action>) -> 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<B: Backend>(&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::<B>(
+
            frame,
+
            area,
+
            TableProps {
+
                items: self.props.issues.to_vec(),
+
                focus: false,
+
                widths,
+
                header,
+
                footer: Some(footer),
+
            },
+
        );
+
    }
+
}