mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-10-31 08:50:48 +08:00
issue: Implement select app
This commit is contained in:
+34
-6
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
#[path = "flux/select.rs"]
|
||||
pub mod select;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user