issue: Implement select app

This commit is contained in:
Erik Kundt
2024-02-23 16:09:46 +01:00
parent 937ae12bce
commit 4b2eeef240
5 changed files with 469 additions and 6 deletions
+34 -6
View File
@@ -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(())
}
+33
View File
@@ -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")
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
#[path = "flux/select.rs"]
pub mod select;
+122
View File
@@ -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");
}
}
}
+278
View File
@@ -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),
},
);
}
}